feat: Agent 多模式系统、视觉模型集成、LLM 能力分层与 P3 性能收尾

核心架构变更:

  1. Agent 多模式系统替代 Coordinator
     - 移除 src/agent/coordinator/(Coordinator Agent/Worker/Tools,946 行)
     - 新建 src/agent/modes/:声明式模式抽象(AgentMode/ModeConfig/ToolSet)
     - 三种内置模式:
       - default:通用科研助手,零覆盖保持现有行为
       - deep-research:16 步、启用思考、research 权限、系统性调研
       - literature-reader:白名单工具、只读沙箱、结构化阅读
     - ModeRegistry + ModeConfig 预设 + ToolSet 过滤 + 身份/原则覆盖
     - AgentRuntime::with_mode() 统一入口,模式持久化到 session.mode 字段
     - GET /api/chat/modes 提供模式列表给前端选择器

  2. 视觉模型与图片分析
     - 新增 analyze_image 工具(340 行):本地/URL 图片 → 视觉模型流式分析
     - LlmClient::analyze_image_stream():SSE 增量实时推送
     - 配置:LLM_VISION_MODEL / LLM_VISION_API_KEY / LLM_VISION_API_BASE
     - 前端:粘贴/选择图片附件,重试时复用文件路径
     - Service 层移除 /chat/rag 和 /chat/figure 端点,统一走 Agent SSE
     - Body limit 提升至 100MB 适配大图上传

  3. LLM 三级能力分层
     - Tier 1 (Core) → Tier 2 (Medium) → Tier 3 (Fast),级联回退
     - medium_llm / fast_llm / vision_llm 注入 AppState
     - 资产批量翻译 → Medium LLM + Semaphore(3) 并发控制
     - 记忆提取/上下文压缩子代理 → Fast LLM
     - SubAgentRunner::with_llm_client() 支持注入专用 LLM

  4. 数据库与性能优化
     - SQLite 启用 WAL + busy_timeout(10s) 处理并发写入
     - RAG ingest:DELETE 合并为原子语句 + 批量事务写入
     - Meta sync:save_paper_to_db_tx() 事务化批量插入
     - 翻译词典:first_words HashSet 预过滤 + next_valid_index 跳跃优化
     - read_file 不截断输出 + skip_persist 防止级联磁盘持久化

  5. 工具系统增强
     - ToolContext 增加 tool_call_id + max_output_chars
     - ToolOutput 增加 skip_persist 标记
     - TextDelta SSE 携带可选 tool_call_id 支持工具的流式输出
     - ChatMessage::text() 辅助方法
This commit is contained in:
fmq
2026-06-24 19:52:27 +08:00
parent cec4b8cf7b
commit 85b6429c30
44 changed files with 2307 additions and 1578 deletions
+15 -8
View File
@@ -671,7 +671,9 @@ pub async fn execute_parallel(
.with_sse_tx(tx.clone())
.with_session_id(session_id.to_string())
.with_thinking(enable_thinking)
.with_additional_dirs(additional_allowed_dirs.clone());
.with_additional_dirs(additional_allowed_dirs.clone())
.with_tool_call_id(prep.tool_call_id.clone())
.with_max_output_chars(max_output_chars);
let cancelled = cancelled.clone();
let tool_opt = tool_registry.get(&tool_name);
@@ -884,13 +886,18 @@ async fn process_single_result(
});
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
// 但对于已从磁盘读取内容的工具(如 read_file),跳过持久化以防止级联
let tool_results_dir = library_dir.join("tool-results");
let (processed_content, _persisted_path) = maybe_persist_tool_result(
&output.content,
tool_call_id,
max_output_chars,
&tool_results_dir,
);
let (processed_content, _persisted_path) = if output.skip_persist {
(output.content.clone(), None)
} else {
maybe_persist_tool_result(
&output.content,
tool_call_id,
max_output_chars,
&tool_results_dir,
)
};
// PostToolUse hook
let post_ctx = PostToolUseContext {
@@ -977,7 +984,7 @@ fn save_tool_message_sync(
) {
let db_clone = db.clone();
let session_id = session_id.to_string();
let content = msg.content.as_deref().unwrap_or("").to_string();
let content = msg.text().unwrap_or("").to_string();
let tool_call_id = msg.tool_call_id.clone();
// 提前序列化,避免闭包内的生命周期问题
let metadata_str =
+225 -62
View File
@@ -43,6 +43,7 @@ use super::compact;
use super::hooks::{
HookRegistry, SessionStartContext, StepCompleteContext, UserPromptSubmitContext,
};
use super::modes::{self, AgentMode, ModeRegistry};
use super::terminal::TurnTerminal;
use super::tools::ToolRegistry;
use crate::api::AppState;
@@ -91,6 +92,8 @@ pub struct AgentConfig {
pub additional_allowed_dirs: Vec<String>,
/// 子代理工具白名单(逗号分隔,空=全部工具可用)
pub subagent_allowed_tools: Vec<String>,
/// Agent 运行模式 ID"default" / "deep-research" / "literature-reader"
pub mode: String,
}
impl AgentConfig {
@@ -142,6 +145,7 @@ impl AgentConfig {
.unwrap_or(20),
additional_allowed_dirs: parse_comma_list("AGENT_ADDITIONAL_DIRS"),
subagent_allowed_tools: parse_comma_list("AGENT_SUBAGENT_ALLOWED_TOOLS"),
mode: std::env::var("AGENT_MODE").unwrap_or_else(|_| "default".to_string()),
};
// 加载权限档案(AGENT_PERMISSION_PROFILE),追加到现有规则
@@ -223,9 +227,15 @@ pub enum AgentStreamEvent {
metadata: serde_json::Value,
step: usize,
},
/// 文本增量流式输出(最终回答)
/// 文本增量流式输出(最终回答或工具流式输出
#[serde(rename = "text_delta")]
TextDelta { content: String },
TextDelta {
content: String,
/// 可选:工具调用 ID。当 set 时,此增量属于对应工具的流式输出,
/// 前端应将其渲染到工具结果区域而非主文本区。
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
},
/// Token 使用统计
#[serde(rename = "usage")]
Usage {
@@ -315,14 +325,27 @@ pub struct AgentRuntime {
collapse_log: Arc<std::sync::Mutex<compact::collapse::CollapseLog>>,
/// Checkpoint 管理器(跨 turn 共享,文件变更操作前自动快照)
checkpoint_manager: Arc<checkpoint::CheckpointManager>,
/// 是否启用协调者模式(Coordinator delegates to Workers
coordinator_mode: bool,
/// 当前运行模式(从 ModeRegistry 解析的静态引用
mode: &'static AgentMode,
/// 模式注册表(持有所有已注册模式)
mode_registry: ModeRegistry,
}
impl AgentRuntime {
/// 创建新的运行时实例
pub fn new(app_state: Arc<AppState>) -> Self {
let config = AgentConfig::default();
let mut config = AgentConfig::default();
let mode_registry = ModeRegistry::builtins();
let mode = mode_registry.get(&config.mode).copied().unwrap_or_else(|| {
tracing::warn!("[AgentRuntime] 未知模式 '{}',回退到默认模式", config.mode);
mode_registry
.get(ModeRegistry::default_id())
.copied()
.unwrap_or(&modes::default::DEFAULT_MODE)
});
// 合并模式配置预设到 AgentConfig
apply_mode_config(&mut config, mode);
let queue = Arc::new(BgNotificationQueue::new());
let metrics_data = Arc::new(std::sync::Mutex::new(super::hooks::MetricsData::default()));
let permission_checker = Arc::new(permission::PermissionChecker::from_config(&config));
@@ -348,6 +371,14 @@ impl AgentRuntime {
*session_checker = (*permission_checker).clone();
}
// 视觉模型可用时注册 analyze_image 工具
if app_state.vision_llm.is_some() {
tool_registry.add_tool(Box::new(crate::agent::tools::astro::AnalyzeImageTool));
}
// ── 应用模式的工具集过滤 ──
apply_mode_tool_filter(&mut tool_registry, mode);
// 初始化 checkpoint 管理器
let checkpoint_enabled = std::env::var("AGENT_CHECKPOINT_ENABLED")
.unwrap_or_else(|_| "true".to_string())
@@ -374,12 +405,24 @@ impl AgentRuntime {
prompt_cache: std::sync::Mutex::new(SystemPromptCache::new()),
collapse_log: Arc::new(std::sync::Mutex::new(compact::collapse::CollapseLog::new())),
checkpoint_manager,
coordinator_mode: false,
mode,
mode_registry,
}
}
/// 创建带自定义配置的运行时实例
pub fn with_config(app_state: Arc<AppState>, config: AgentConfig) -> Self {
pub fn with_config(app_state: Arc<AppState>, mut config: AgentConfig) -> Self {
let mode_registry = ModeRegistry::builtins();
let mode = mode_registry.get(&config.mode).copied().unwrap_or_else(|| {
tracing::warn!("[AgentRuntime] 未知模式 '{}',回退到默认模式", config.mode);
mode_registry
.get(ModeRegistry::default_id())
.copied()
.unwrap_or(&modes::default::DEFAULT_MODE)
});
// 合并模式配置预设
apply_mode_config(&mut config, mode);
let queue = Arc::new(BgNotificationQueue::new());
let metrics_data = Arc::new(std::sync::Mutex::new(super::hooks::MetricsData::default()));
let permission_checker = Arc::new(permission::PermissionChecker::from_config(&config));
@@ -403,6 +446,14 @@ impl AgentRuntime {
*session_checker = (*permission_checker).clone();
}
// 视觉模型可用时注册 analyze_image 工具
if app_state.vision_llm.is_some() {
tool_registry.add_tool(Box::new(crate::agent::tools::astro::AnalyzeImageTool));
}
// ── 应用模式的工具集过滤 ──
apply_mode_tool_filter(&mut tool_registry, mode);
// 初始化 checkpoint 管理器
let checkpoint_enabled = std::env::var("AGENT_CHECKPOINT_ENABLED")
.unwrap_or_else(|_| "true".to_string())
@@ -429,7 +480,8 @@ impl AgentRuntime {
prompt_cache: std::sync::Mutex::new(SystemPromptCache::new()),
collapse_log: Arc::new(std::sync::Mutex::new(compact::collapse::CollapseLog::new())),
checkpoint_manager,
coordinator_mode: false,
mode,
mode_registry,
}
}
@@ -442,45 +494,41 @@ impl AgentRuntime {
.unwrap_or_default()
}
/// 设置是否启用 LLM 思考模式
/// 设置是否启用 LLM 思考模式(向后兼容,优先使用 mode 设置)
pub fn with_thinking(mut self, enable: bool) -> Self {
self.config.enable_thinking = enable;
self
}
/// 设置是否启用协调者模式
pub fn with_coordinator_mode(mut self, enabled: bool) -> Self {
self.coordinator_mode = enabled;
/// 设置运行模式(覆盖 AgentConfig 中的 mode 字段)。
///
/// 调用此方法会重新解析模式并应用对应的配置预设、工具过滤和权限档案。
pub fn with_mode(mut self, mode_id: &str) -> Self {
self.config.mode = mode_id.to_string();
let mode = self.mode_registry.get(mode_id).copied().unwrap_or_else(|| {
tracing::warn!(
"[AgentRuntime] with_mode: 未知模式 '{}',回退到默认模式",
mode_id
);
self.mode_registry
.get(ModeRegistry::default_id())
.copied()
.unwrap_or(&modes::default::DEFAULT_MODE)
});
apply_mode_config(&mut self.config, mode);
apply_mode_tool_filter(&mut self.tool_registry, mode);
self.mode = mode;
self
}
// ── Coordinator Mode ──
/// 返回当前模式的 ID。
pub fn mode_id(&self) -> &str {
self.mode.id
}
/// 运行协调者模式 turn:创建 CoordinatorAgent 并委托执行
async fn run_coordinator_turn(
&self,
session_info: &session::SessionInfo,
question: &str,
tx: mpsc::UnboundedSender<AgentStreamEvent>,
) -> anyhow::Result<String> {
use super::coordinator::agent::CoordinatorAgent;
use super::coordinator::CoordinatorConfig;
let coordinator_config = CoordinatorConfig::default();
let coordinator = CoordinatorAgent::new(
self.app_state.clone(),
self.config.clone(),
coordinator_config,
);
coordinator
.run(
&session_info.session_id,
question,
session_info.turn_index,
tx,
)
.await
/// 返回模式是否强制固定了 thinking。`Some(true/false)` 表示模式已锁定,用户不可覆盖
pub fn mode_fixed_thinking(&self) -> Option<bool> {
self.mode.mode_config.enable_thinking
}
// ── Private Helpers ──
@@ -508,8 +556,7 @@ impl AgentRuntime {
};
// 压缩前捕获消息快照(用于记忆提取桥接,P3)
let pre_compact_snapshot: Vec<crate::clients::llm::ChatMessage> =
messages.iter().cloned().collect();
let pre_compact_snapshot: Vec<crate::clients::llm::ChatMessage> = messages.to_vec();
compact::compress_context_with_hooks_and_log(
messages,
@@ -563,17 +610,28 @@ impl AgentRuntime {
session_id: Option<String>,
question: &str,
tx: mpsc::UnboundedSender<AgentStreamEvent>,
) -> anyhow::Result<String> {
self.run_turn_with_image_context(session_id, question, None, None, tx)
.await
}
/// 带图片上下文的对话回合。`image_context` 会在用户消息前注入为 system-reminder。
/// `image_path` 会存入用户消息的 metadata 以便前端渲染。
pub async fn run_turn_with_image_context(
&self,
session_id: Option<String>,
question: &str,
image_context: Option<String>,
image_path: Option<String>,
tx: mpsc::UnboundedSender<AgentStreamEvent>,
) -> anyhow::Result<String> {
let db = &self.app_state.db;
let llm = &self.app_state.llm;
// Phase 1: 创建或恢复会话
let session_info = session::create_or_resume_session(db, session_id.clone(), llm).await?;
// 协调者模式分支:委托给 CoordinatorAgent
if self.coordinator_mode {
return self.run_coordinator_turn(&session_info, question, tx).await;
}
let session_info =
session::create_or_resume_session(db, session_id.clone(), llm, &self.config.mode)
.await?;
// 构建 hook 注册表(注入依赖,复用 AgentRuntime 的 metrics_data
let hook_registry = HookRegistry::with_builtins(
@@ -615,7 +673,19 @@ impl AgentRuntime {
)
.await?;
// 保存用户消息到数据库
// 图片上下文:在用户消息前注入 system-reminder
if let Some(ref img_ctx) = image_context {
let reminder = format!("<system-reminder>\n{}\n</system-reminder>", img_ctx);
// 插入到倒数第二条位置(用户消息之前)
let user_msg = messages.pop().unwrap(); // 用户消息
messages.push(ChatMessage::user(&reminder));
messages.push(user_msg);
}
// 保存用户消息到数据库(含图片路径元数据,供前端历史渲染)
let user_metadata = image_path
.as_ref()
.map(|p| serde_json::json!({"image_path": p}));
self.save_message(
db,
&session_info.session_id,
@@ -623,6 +693,7 @@ impl AgentRuntime {
0,
&ChatMessage::user(question),
None,
user_metadata,
)
.await?;
@@ -934,6 +1005,7 @@ impl AgentRuntime {
step as i32,
&assistant_msg,
stream_output.reasoning.as_deref(),
None,
)
.await?;
messages.push(assistant_msg);
@@ -1008,6 +1080,7 @@ impl AgentRuntime {
step as i32,
&assistant_msg,
stream_output.reasoning.as_deref(),
None,
)
.await?;
messages.push(assistant_msg);
@@ -1427,6 +1500,7 @@ impl AgentRuntime {
/// 1. 所有静态 section 在前 → 内容不变,服务端自然缓存
/// 2. 动态 sectionenvironment/tools/skills/memory)在后
/// 3. 使用 SystemPromptCache:首次计算后永久复用,/clear 时失效
/// 4. 模式(AgentMode)可覆盖 identity 和 principles section
fn system_prompt(&self) -> String {
use self::system_prompt::{
SystemPrompt, IDENTITY_SECTION, PRINCIPLES_SECTION, SAFETY_SECTION,
@@ -1436,20 +1510,32 @@ impl AgentRuntime {
let mut sp = SystemPrompt::new();
// ═══════ 静态 section(首次计算后永久缓存)═══════
// 注意:identity 和 principles 可能被 mode 覆盖
let mut cache = self.prompt_cache.lock().unwrap_or_else(|e| {
tracing::warn!("[SystemPrompt] 缓存锁异常: {:?}", e);
e.into_inner()
});
sp.add_section(
"identity",
cache.get_or_compute("identity", || IDENTITY_SECTION.to_string()),
);
sp.add_section(
"principles",
cache.get_or_compute("principles", || PRINCIPLES_SECTION.to_string()),
);
// Identity:优先使用模式的覆盖,否则使用默认
let identity = match self.mode.identity_override {
Some(override_text) => override_text.to_string(),
None => cache.get_or_compute("identity", || IDENTITY_SECTION.to_string()),
};
sp.add_section("identity", identity);
// Principles:优先使用模式的覆盖,否则使用默认
let principles = match self.mode.principles_override {
Some(override_text) => override_text.to_string(),
None => cache.get_or_compute("principles", || PRINCIPLES_SECTION.to_string()),
};
sp.add_section("principles", principles);
// 模式的额外 section(追加在静态 section 之后)
for (name, content) in self.mode.extra_sections {
sp.add_section(name, content.to_string());
}
sp.add_section(
"system_context",
cache.get_or_compute("system_context", || SYSTEM_CONTEXT_SECTION.to_string()),
@@ -1472,7 +1558,10 @@ impl AgentRuntime {
// 工具目录:使用 tool_catalog() 列出常驻+延迟工具(P3 defer_loading 集成)
let tools_section = cache.get_or_compute("tools", || {
let catalog = self.tool_registry.tool_catalog();
format!("你可以使用以下工具([deferred] 标记的工具需要通过 load_skill 发现详情):\n{}", catalog)
format!(
"你可以使用以下工具([deferred] 标记的工具需要通过 load_skill 发现详情):\n{}",
catalog
)
});
sp.add_section("tools", tools_section);
@@ -1595,7 +1684,10 @@ impl AgentRuntime {
match event {
StreamEvent::TextDelta(delta) => {
accumulated.push_str(&delta);
let _ = tx.send(AgentStreamEvent::TextDelta { content: delta });
let _ = tx.send(AgentStreamEvent::TextDelta {
content: delta,
tool_call_id: None,
});
}
StreamEvent::Usage(u) => {
let _ = tx.send(AgentStreamEvent::Usage {
@@ -1623,6 +1715,7 @@ impl AgentRuntime {
step as i32,
&assistant_msg,
None,
None,
)
.await?;
@@ -1630,6 +1723,7 @@ impl AgentRuntime {
}
/// 保存消息到数据库
#[allow(clippy::too_many_arguments)]
async fn save_message(
&self,
db: &SqlitePool,
@@ -1638,9 +1732,19 @@ impl AgentRuntime {
step_index: i32,
msg: &ChatMessage,
thought: Option<&str>,
extra_metadata: Option<serde_json::Value>,
) -> anyhow::Result<()> {
self.save_message_as(db, session_id, turn_index, step_index, msg, thought, "lead")
.await
self.save_message_as(
db,
session_id,
turn_index,
step_index,
msg,
thought,
"lead",
extra_metadata,
)
.await
}
/// 保存消息到数据库(指定 agent 身份)
@@ -1654,6 +1758,7 @@ impl AgentRuntime {
msg: &ChatMessage,
thought: Option<&str>,
agent_name: &str,
extra_metadata: Option<serde_json::Value>,
) -> anyhow::Result<()> {
let role = match msg.role {
MessageRole::System => "system",
@@ -1662,7 +1767,7 @@ impl AgentRuntime {
MessageRole::Tool => "tool",
};
let content = msg.content.as_deref().unwrap_or("");
let content = msg.content.clone().unwrap_or_default();
let tool_calls_json = msg
.tool_calls
.as_ref()
@@ -1671,11 +1776,19 @@ impl AgentRuntime {
let token_count = content.len() as i32 / 4;
// metadata: 存储结构化的消息元信息(thought/tool_calls/tool_call_id 等)
let metadata = serde_json::json!({
let mut metadata = serde_json::json!({
"has_thought": thought.is_some(),
"has_tool_calls": tool_calls_json.is_some(),
"step_index": step_index,
});
// 合并额外元数据(如图片路径等)
if let Some(ref extra) = extra_metadata {
if let (Some(base), Some(extra_obj)) = (metadata.as_object_mut(), extra.as_object()) {
for (k, v) in extra_obj {
base.insert(k.clone(), v.clone());
}
}
}
let metadata_str = serde_json::to_string(&metadata).unwrap_or_default();
// raw_json: 存储完整消息的 JSON 序列化(调试/审计用)
@@ -1718,7 +1831,7 @@ impl AgentRuntime {
) {
if let Err(e) = self
.save_message_as(
db, session_id, turn_index, step_index, msg, thought, agent_name,
db, session_id, turn_index, step_index, msg, thought, agent_name, None,
)
.await
{
@@ -1726,3 +1839,53 @@ impl AgentRuntime {
}
}
}
// ── Mode helper functions ────────────────────────────────────────────────
/// 将模式的 AgentConfig 预设合并到给定的 config 中。
///
/// 仅覆盖 mode_config 中 Some 的字段,None 保持原值不变。
fn apply_mode_config(config: &mut AgentConfig, mode: &AgentMode) {
if let Some(max_steps) = mode.mode_config.max_steps {
config.max_steps = max_steps;
}
if let Some(enable_thinking) = mode.mode_config.enable_thinking {
config.enable_thinking = enable_thinking;
}
if let Some(tool_timeout_secs) = mode.mode_config.tool_timeout_secs {
config.tool_timeout_secs = tool_timeout_secs;
}
// 加载权限档案(模式绑定的 permission_profile
if let Some(profile_name) = mode.mode_config.permission_profile {
if let Some(profile) = permission_profile::load_profile(profile_name) {
tracing::info!(
"[AgentRuntime] 模式 '{}' 加载权限档案: {} — {}",
mode.id,
profile.name,
profile.description
);
permission_profile::apply_profile_to_config(
&profile,
&mut config.permission_deny_rules,
&mut config.permission_allow_rules,
&mut config.permission_ask_rules,
&mut config.permission_mode,
);
}
}
}
/// 根据模式的 ToolSet 设置工具注册表的定义过滤器。
fn apply_mode_tool_filter(tool_registry: &mut ToolRegistry, mode: &AgentMode) {
let all_names = tool_registry.tool_names();
if let Some(filter) = modes::tool_set_to_filter(&mode.tool_set, &all_names) {
tracing::info!(
"[AgentRuntime] 模式 '{}' 工具过滤: {} → {} 个工具",
mode.id,
all_names.len(),
filter.len()
);
tool_registry.set_definition_filter(filter);
}
}
+67 -28
View File
@@ -13,6 +13,8 @@ use crate::clients::llm::LlmClient;
pub struct SessionInfo {
pub session_id: String,
pub turn_index: i32,
/// 会话的运行模式 ID"default" / "deep-research" / "literature-reader"
pub mode: String,
}
/// 回退操作结果
@@ -27,10 +29,14 @@ pub struct RewindResult {
}
/// 创建新会话或恢复已有会话。
///
/// `mode` 指定会话的运行模式(如 "default"、"deep-research")。
/// 新建会话时写入 mode;恢复会话时从 DB 读取 mode(忽略传入的 mode 参数)。
pub async fn create_or_resume_session(
db: &SqlitePool,
session_id: Option<String>,
llm: &LlmClient,
mode: &str,
) -> anyhow::Result<SessionInfo> {
match session_id {
Some(id) => {
@@ -46,6 +52,14 @@ pub async fn create_or_resume_session(
return Err(anyhow::anyhow!("会话 {} 不存在或已删除", id));
}
// 从 DB 恢复 mode(保持会话创建时的原始模式)
let session_mode: String =
sqlx::query_scalar("SELECT mode FROM agent_sessions WHERE session_id = ?")
.bind(&id)
.fetch_one(db)
.await
.unwrap_or_else(|_| "default".to_string());
// 计算当前轮次号(仅统计 active=1 的消息)
let turn_index: i32 = sqlx::query_scalar(
"SELECT COALESCE(MAX(turn_index), -1) + 1 FROM agent_messages \
@@ -59,25 +73,44 @@ pub async fn create_or_resume_session(
Ok(SessionInfo {
session_id: id,
turn_index,
mode: session_mode,
})
}
None => {
let new_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO agent_sessions (session_id, title, model) VALUES (?, ?, ?)")
.bind(&new_id)
.bind("")
.bind(llm.model())
.execute(db)
.await?;
sqlx::query(
"INSERT INTO agent_sessions (session_id, title, model, mode) VALUES (?, ?, ?, ?)",
)
.bind(&new_id)
.bind("")
.bind(llm.model())
.bind(mode)
.execute(db)
.await?;
Ok(SessionInfo {
session_id: new_id,
turn_index: 0,
mode: mode.to_string(),
})
}
}
}
/// 从数据库加载会话的运行模式。
///
/// 返回 None 表示会话不存在或已删除。
pub async fn load_session_mode(db: &SqlitePool, session_id: &str) -> Option<String> {
sqlx::query_scalar(
"SELECT mode FROM agent_sessions WHERE session_id = ? AND deleted_at IS NULL",
)
.bind(session_id)
.fetch_optional(db)
.await
.ok()
.flatten()
}
/// 加载会话的历史消息(供 LLM 上下文使用)。
pub async fn load_history_for_llm(
db: &SqlitePool,
@@ -397,10 +430,13 @@ pub async fn restore_rewound(db: &SqlitePool, session_id: &str) -> anyhow::Resul
///
/// 返回 `(deleted_message_text, new_turn_index)`。
/// 如果没有找到用户消息,返回错误。
pub async fn retry_last_turn(db: &SqlitePool, session_id: &str) -> anyhow::Result<(String, i32)> {
// 查找最后一条 user 消息(仅 active=1
let last_user: Option<(i64, String, i32)> = sqlx::query_as(
"SELECT id, content, turn_index FROM agent_messages \
pub async fn retry_last_turn(
db: &SqlitePool,
session_id: &str,
) -> anyhow::Result<(String, i32, Option<String>)> {
// 查找最后一条 user 消息(仅 active=1),同时读取 metadata 中的 image_path
let last_user: Option<(i64, String, i32, Option<String>)> = sqlx::query_as(
"SELECT id, content, turn_index, metadata FROM agent_messages \
WHERE session_id = ? AND role = 'user' AND active = 1 \
ORDER BY id DESC LIMIT 1",
)
@@ -408,11 +444,16 @@ pub async fn retry_last_turn(db: &SqlitePool, session_id: &str) -> anyhow::Resul
.fetch_optional(db)
.await?;
let (target_id, message_text, _turn) = match last_user {
let (target_id, message_text, _turn, metadata_str) = match last_user {
Some(t) => t,
None => return Err(anyhow::anyhow!("没有找到可重试的用户消息")),
};
// 从 metadata JSON 中提取 image_path
let image_path: Option<String> = metadata_str
.and_then(|m| serde_json::from_str::<serde_json::Value>(&m).ok())
.and_then(|v| v.get("image_path")?.as_str().map(|s| s.to_string()));
// 硬删除 >= target_id 的所有消息(含 active=0 的历史回退消息)
let deleted: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM agent_messages \
@@ -443,11 +484,15 @@ pub async fn retry_last_turn(db: &SqlitePool, session_id: &str) -> anyhow::Resul
.unwrap_or(0);
info!(
"[Session] 重试: session={}, deleted={} messages from id={}, new_turn={}",
session_id, deleted, target_id, new_turn_index
"[Session] 重试: session={}, deleted={} messages from id={}, new_turn={}, has_image={}",
session_id,
deleted,
target_id,
new_turn_index,
image_path.is_some()
);
Ok((message_text, new_turn_index))
Ok((message_text, new_turn_index, image_path))
}
/// 分叉结果
@@ -688,10 +733,7 @@ mod tests {
// Actually, rewind_to_message(2) soft-deletes id >= 2
// So only system (id=1) remains
assert_eq!(msgs.len(), 1);
assert_eq!(
msgs[0].content.as_deref(),
Some("You are a helpful assistant.")
);
assert_eq!(msgs[0].text(), Some("You are a helpful assistant."));
}
#[tokio::test]
@@ -794,14 +836,14 @@ mod tests {
seed_messages(&db, sid).await;
// Last user message is "Question 3" (id=6)
let (msg, new_turn) = retry_last_turn(&db, sid).await.unwrap();
let (msg, new_turn, _) = retry_last_turn(&db, sid).await.unwrap();
assert_eq!(msg, "Question 3");
assert_eq!(new_turn, 2); // turn_index after removing id=6,7
// Only messages 1-5 should remain
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 5);
assert_eq!(msgs.last().unwrap().content.as_deref(), Some("Answer 2"));
assert_eq!(msgs.last().unwrap().text(), Some("Answer 2"));
}
#[tokio::test]
@@ -829,16 +871,13 @@ mod tests {
// Now: ids 1-3 active=1, ids 4-7 active=0
// Now retry — should hard DELETE from last active user message (id=2)
let (msg, _new_turn) = retry_last_turn(&db, sid).await.unwrap();
let (msg, _, _) = retry_last_turn(&db, sid).await.unwrap();
assert_eq!(msg, "Question 1"); // last active user message
// Only system message should remain
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(
msgs[0].content.as_deref(),
Some("You are a helpful assistant.")
);
assert_eq!(msgs[0].text(), Some("You are a helpful assistant."));
// Even inactive messages (4-7) should be gone (hard DELETE)
let all_count: i64 =
@@ -900,14 +939,14 @@ mod tests {
let branch_msgs = load_history_for_llm(&db, &bid).await.unwrap();
let has_branch_msg = branch_msgs
.iter()
.any(|m| m.content.as_deref() == Some("Branch question"));
.any(|m| m.text() == Some("Branch question"));
assert!(has_branch_msg);
// Original does NOT see branch message
let orig_msgs = load_history_for_llm(&db, sid).await.unwrap();
let has_branch_msg = orig_msgs
.iter()
.any(|m| m.content.as_deref() == Some("Branch question"));
.any(|m| m.text() == Some("Branch question"));
assert!(!has_branch_msg);
}
@@ -955,7 +994,7 @@ mod tests {
// Retry: last active user is id=2. Hard DELETE ids >= 2.
// This removes everything: 1 (system), 2 (user), 3 (asst), and 4-7 (inactive)
let (msg, _) = retry_last_turn(&db, sid).await.unwrap();
let (msg, _, _) = retry_last_turn(&db, sid).await.unwrap();
assert_eq!(msg, "Question 1");
// Only system remains
+2
View File
@@ -35,6 +35,7 @@ pub enum StreamStatus {
///
/// 使用 tokio::select! 在流式读取和取消信号之间竞速。
/// 实时发送 Thought/TextDelta SSE 事件给前端。
#[allow(clippy::too_many_arguments)]
pub async fn process_llm_stream(
llm: &LlmClient,
messages: &[ChatMessage],
@@ -106,6 +107,7 @@ pub async fn process_llm_stream(
if !is_tool_call_step {
let _ = tx.send(AgentStreamEvent::TextDelta {
content: delta,
tool_call_id: None,
});
}
}
+1
View File
@@ -285,6 +285,7 @@ impl StreamingToolExecutor {
),
is_error: result.is_error,
metadata: result.metadata,
skip_persist: result.skip_persist,
}
} else {
result