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:
@@ -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
@@ -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. 动态 section(environment/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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@ impl StreamingToolExecutor {
|
||||
),
|
||||
is_error: result.is_error,
|
||||
metadata: result.metadata,
|
||||
skip_persist: result.skip_persist,
|
||||
}
|
||||
} else {
|
||||
result
|
||||
|
||||
Reference in New Issue
Block a user