Compare commits
No commits in common. "8a4799656cec7980bc86aafd9632e1b3193f7719" and "3faefc74b9f12f325a3b2392bf17a2b85016475a" have entirely different histories.
8a4799656c
...
3faefc74b9
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -1728,7 +1728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picobot"
|
||||
version = "0.4.2"
|
||||
version = "0.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "0.4.2"
|
||||
version = "0.4.1"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
|
||||
@ -2,45 +2,6 @@
|
||||
|
||||
本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。
|
||||
|
||||
## [0.4.2] - 2026-08-17
|
||||
|
||||
较 [0.4.1] 的 8 个 commit 迭代,聚焦 **安全漏洞修复**、**全链路性能优化** 与 **前端健壮性** 三大方向。
|
||||
|
||||
### 新增功能
|
||||
|
||||
#### 部署安全加固
|
||||
- 前端生产构建产物与配置文件中,输出/展示时排除所有 token 相关敏感字段(`api_key`/`auth_token`/`access_token` 等),防止误写入日志或静态文件泄露凭据。
|
||||
|
||||
### 性能优化
|
||||
|
||||
#### 前端流式渲染性能(子代理场景 P0)
|
||||
- 子代理 `stream_delta` 用 `requestAnimationFrame` 批处理 delta 合并,避免每 token 一次 setState 触发子树重渲染。
|
||||
- 消息槽(MessageBubble / SubagentCard)用 `React.memo` 包裹,流式期间跳过未变更项的 diff。
|
||||
- base64 附件解码改为分块处理,大文件解码时不阻塞主线程。
|
||||
|
||||
#### Bash 输出增量匹配与 HTTP 工具性能
|
||||
- Bash 增量匹配:每一次增量输出原先用 O(n²) 算法寻找已有匹配段,改为基于游标增量扫描,长 stdout 场景 CPU 占用显著下降。
|
||||
- Bash 缓冲上限:新增流式缓冲大小限制(默认 2MB),超出时按行截断,防止异常大输出撑爆内存。
|
||||
- `http_request` 复用 `reqwest::Client`(之前每次调用新建),建立连接池;响应体保持流式分块读取并累加限长。
|
||||
|
||||
#### 数据库热路径优化
|
||||
- 热路径同步 SQLite 操作(`get_topic_message_count`、`load_messages_for_topic_full` 等)改 `tokio::task::spawn_blocking`,解除 tokio worker 线程阻塞,多 topic 并行场景吞吐提升。
|
||||
- 定向查询优化:只查询需要的列(不加载 tool_calls_json / content 等大字段),减少 DB → 内存拷贝。
|
||||
|
||||
### 修复
|
||||
|
||||
#### 安全漏洞
|
||||
- 升级 `quinn-proto` 0.11.14 → 0.11.16,修复 **RUSTSEC-2026-0185** 远程内存耗尽漏洞(QUIC 握手畸形数据包导致的无界内存分配)。
|
||||
- `npm audit fix` 修复前端 4 个高危依赖漏洞:`vite` / `postcss` / `undici` / `nanoid`。
|
||||
|
||||
#### 子代理运行中状态丢失
|
||||
- 历史加载时对账 task running 占位:刷新页面或切换话题后,子代理卡片不再因 running 占位缺失而永远显示「运行中」。
|
||||
|
||||
### 重构
|
||||
- 清理 clippy 存量告警:替换不必要的 `unwrap()` 为 match/`?`,去除多余 `.clone()`,删除冗余分支与死代码。
|
||||
|
||||
---
|
||||
|
||||
## [0.4.1] - 2026-08-14
|
||||
|
||||
较 [0.4.0] 的 17 个 commit 迭代,聚焦 **前端视觉重构**、**话题级模型选择**、**定时任务可靠性**、**缓存占比统计** 与 **调度器并发** 五大方向。
|
||||
@ -634,7 +595,6 @@
|
||||
- 前端静态文件嵌入二进制。
|
||||
- React Web UI 前端界面。
|
||||
|
||||
[0.4.2]: https://github.com/picobot/picobot/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/picobot/picobot/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/picobot/picobot/compare/v0.3.5...v0.4.0
|
||||
[0.3.5]: https://github.com/picobot/picobot/compare/v0.3.4...v0.3.5
|
||||
|
||||
@ -337,14 +337,15 @@ fn filter_images_by_age_and_count(
|
||||
.count();
|
||||
|
||||
let content = if original_image_count > filtered_image_count {
|
||||
if exceeds_age_limit {
|
||||
let notice = if exceeds_age_limit {
|
||||
format!(
|
||||
"{} [图片已过期:超出 {} 条消息范围]",
|
||||
message.content, max_age_rounds
|
||||
)
|
||||
} else {
|
||||
format!("{} [图片已过期:超出最大图片数量限制]", message.content)
|
||||
}
|
||||
};
|
||||
notice
|
||||
} else {
|
||||
message.content.clone()
|
||||
};
|
||||
@ -613,7 +614,7 @@ impl LoopDetector {
|
||||
.count();
|
||||
|
||||
// Warn every warn_every times
|
||||
if consecutive > 0 && consecutive.is_multiple_of(self.config.warn_every) {
|
||||
if consecutive > 0 && consecutive % self.config.warn_every == 0 {
|
||||
LoopDetectionResult::Warning(format!(
|
||||
"注意: 工具 '{}' 已连续执行 {} 次,参数相同。如果任务没有进展,请尝试其他方法。",
|
||||
last.name, consecutive
|
||||
@ -1138,7 +1139,7 @@ impl AgentLoop {
|
||||
// 避免每轮 serde_json::to_string 全量序列化工具定义。
|
||||
let tools_tokens = tools
|
||||
.as_ref()
|
||||
.map(estimate_tokens_from_serialized_json)
|
||||
.map(|t| estimate_tokens_from_serialized_json(t))
|
||||
.unwrap_or_default();
|
||||
|
||||
for iteration in 0..self.max_iterations {
|
||||
@ -1512,9 +1513,8 @@ impl AgentLoop {
|
||||
.and_then(|m| m.usage.as_ref())
|
||||
.map(|u| u.prompt_tokens);
|
||||
|
||||
if let Some(prompt_tokens) = last_prompt_tokens
|
||||
&& compressor.should_compress_by_usage(prompt_tokens)
|
||||
{
|
||||
if let Some(prompt_tokens) = last_prompt_tokens {
|
||||
if compressor.should_compress_by_usage(prompt_tokens) {
|
||||
// 阶段 1a:工程化压缩(截断非子代理 tool 结果,仅改内存)
|
||||
// 参数内聚到 ContextCompressor,AgentLoop 不持有截断 token 数
|
||||
compressor.truncate_tool_results(&mut messages);
|
||||
@ -1527,7 +1527,8 @@ impl AgentLoop {
|
||||
);
|
||||
|
||||
// 阶段 1b:重新估算,判断是否需要 LLM 压缩(30% 阈值)
|
||||
let estimated = crate::agent::context_compressor::estimate_tokens(&messages);
|
||||
let estimated =
|
||||
crate::agent::context_compressor::estimate_tokens(&messages);
|
||||
if estimated > compressor.llm_compaction_threshold() {
|
||||
tracing::info!(
|
||||
iteration,
|
||||
@ -1537,15 +1538,17 @@ impl AgentLoop {
|
||||
);
|
||||
// LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop
|
||||
match compressor
|
||||
.compress_two_segment_with_provider(&messages, self.provider.as_ref())
|
||||
.compress_two_segment_with_provider(
|
||||
&messages,
|
||||
self.provider.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(compressed) => {
|
||||
// sink 失败时记日志但不中断——内存已压缩,DB 未更新
|
||||
// 下次 process 从 DB 加载时会重新触发压缩
|
||||
if let Some(sink) = compaction_sink
|
||||
&& let Err(e) = sink.compact(&compressed).await
|
||||
{
|
||||
if let Some(sink) = compaction_sink {
|
||||
if let Err(e) = sink.compact(&compressed).await {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
iteration,
|
||||
@ -1553,6 +1556,7 @@ impl AgentLoop {
|
||||
in-memory messages still replaced, DB will be re-compacted next round"
|
||||
);
|
||||
}
|
||||
}
|
||||
messages = compressed;
|
||||
compaction_performed = true;
|
||||
}
|
||||
@ -1576,6 +1580,7 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop continues to next iteration with updated messages
|
||||
// PendingUserAction 工具的结果已在上方加入 messages,
|
||||
@ -2314,14 +2319,14 @@ mod tests {
|
||||
fn test_should_execute_in_parallel_single_tool() {
|
||||
// Would need a proper setup with AgentLoop to test fully
|
||||
// For now, just verify the logic: single tool should return false
|
||||
let calls = [ToolCall {
|
||||
let calls = vec![ToolCall {
|
||||
id: "1".to_string(),
|
||||
name: "test".to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
}];
|
||||
|
||||
// If there's only 1 tool, should return false regardless
|
||||
assert!(calls.len() <= 1);
|
||||
assert_eq!(calls.len() <= 1, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -2614,15 +2619,9 @@ mod tests {
|
||||
let filtered = filter_images_by_age_and_count(&messages, 10, 3);
|
||||
|
||||
// 检查结果
|
||||
assert!(!filtered[19].media_refs.is_empty(), "最新消息应保留图片");
|
||||
assert!(
|
||||
!filtered[15].media_refs.is_empty(),
|
||||
"age=4 的消息应保留图片"
|
||||
);
|
||||
assert!(
|
||||
!filtered[10].media_refs.is_empty(),
|
||||
"age=9 的消息应保留图片"
|
||||
);
|
||||
assert!(filtered[19].media_refs.len() > 0, "最新消息应保留图片");
|
||||
assert!(filtered[15].media_refs.len() > 0, "age=4 的消息应保留图片");
|
||||
assert!(filtered[10].media_refs.len() > 0, "age=9 的消息应保留图片");
|
||||
assert_eq!(filtered[5].media_refs.len(), 0, "age=14 的消息图片应被过滤");
|
||||
assert!(filtered[5].content.contains("超出 10 条消息范围"));
|
||||
assert_eq!(filtered[0].media_refs.len(), 0, "age=19 的消息图片应被过滤");
|
||||
@ -3118,7 +3117,7 @@ mod tests {
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
|
||||
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
|
||||
"no assistant should have tool_calls remaining"
|
||||
);
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ fn is_assistant_with_tool_calls(msg: &ChatMessage) -> bool {
|
||||
&& msg
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.is_some_and(|calls| !calls.is_empty())
|
||||
.map_or(false, |calls| !calls.is_empty())
|
||||
}
|
||||
|
||||
/// Parse a flat message list into atomic units. Orphaned tool results
|
||||
@ -713,8 +713,10 @@ OLDER SEGMENT (events from earlier in the session):
|
||||
let middle_units = &compressible[preserve_count..split];
|
||||
|
||||
// Step 4: Build middle segment messages and transcript
|
||||
let middle_messages: Vec<ChatMessage> =
|
||||
middle_units.iter().flat_map(unit_to_messages).collect();
|
||||
let middle_messages: Vec<ChatMessage> = middle_units
|
||||
.iter()
|
||||
.flat_map(unit_to_messages)
|
||||
.collect();
|
||||
let middle_transcript = Self::build_transcript(&middle_messages);
|
||||
|
||||
// Step 5: Summarize middle segment with LLM (heavy prompt)
|
||||
@ -1114,8 +1116,8 @@ mod tests {
|
||||
fn test_chinese_tokens_higher_than_english() {
|
||||
// Use more characters to make the content difference significant
|
||||
// compared to JSON overhead (50 tokens per message)
|
||||
let english = vec![ChatMessage::user("abcdefghij".repeat(20))]; // 200 English chars
|
||||
let chinese = vec![ChatMessage::user("这是一个测试消息字".repeat(20))]; // 200 CJK chars (10 chars * 20)
|
||||
let english = vec![ChatMessage::user(&"abcdefghij".repeat(20))]; // 200 English chars
|
||||
let chinese = vec![ChatMessage::user(&"这是一个测试消息字".repeat(20))]; // 200 CJK chars (10 chars * 20)
|
||||
|
||||
let english_tokens = estimate_tokens(&english);
|
||||
let chinese_tokens = estimate_tokens(&chinese);
|
||||
@ -1151,7 +1153,7 @@ mod tests {
|
||||
let compressor = ContextCompressor::new(20);
|
||||
// Need more content to trigger compression with new weighted calculation
|
||||
// 200 English chars / 4 = 50 tokens, plus overhead
|
||||
let messages = vec![ChatMessage::user("x".repeat(400))];
|
||||
let messages = vec![ChatMessage::user(&"x".repeat(400))];
|
||||
assert!(compressor.should_compress(&messages));
|
||||
}
|
||||
|
||||
@ -1255,7 +1257,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_chunk_messages_for_summary_splits_oversized_message() {
|
||||
let messages = vec![ChatMessage::user("x".repeat(25))];
|
||||
let messages = vec![ChatMessage::user(&"x".repeat(25))];
|
||||
|
||||
let chunks = ContextCompressor::chunk_messages_for_summary(&messages, 10);
|
||||
|
||||
|
||||
@ -321,17 +321,17 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
||||
for i in (0..messages.len()).rev() {
|
||||
let msg = &messages[i];
|
||||
|
||||
if msg.role == "tool"
|
||||
&& let Some(ref tc_id) = msg.tool_call_id
|
||||
{
|
||||
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()
|
||||
.is_some_and(|calls| !calls.is_empty())
|
||||
.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));
|
||||
@ -379,9 +379,8 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
||||
// If we have pending tool_ids and encounter a non-tool message,
|
||||
// the assistant's tool results were NOT immediately following.
|
||||
if !pending_tool_ids.is_empty() && m.role != "tool" {
|
||||
if let Some(idx) = pending_assistant_idx
|
||||
&& !remove_indices.contains(&idx)
|
||||
{
|
||||
if let Some(idx) = pending_assistant_idx {
|
||||
if !remove_indices.contains(&idx) {
|
||||
tracing::warn!(
|
||||
message_index = idx,
|
||||
interrupted_by_index = i,
|
||||
@ -399,11 +398,15 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
||||
}
|
||||
remove_indices.push(idx);
|
||||
}
|
||||
}
|
||||
pending_tool_ids.clear();
|
||||
pending_assistant_idx = None;
|
||||
}
|
||||
|
||||
if m.role == "assistant" && m.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())
|
||||
if m.role == "assistant"
|
||||
&& m.tool_calls
|
||||
.as_ref()
|
||||
.map_or(false, |calls| !calls.is_empty())
|
||||
{
|
||||
let already_marked = remove_indices.contains(&i);
|
||||
if !already_marked {
|
||||
@ -416,21 +419,20 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
||||
.collect();
|
||||
pending_assistant_idx = Some(i);
|
||||
}
|
||||
} else if m.role == "tool"
|
||||
&& let Some(ref tc_id) = m.tool_call_id
|
||||
{
|
||||
} else if m.role == "tool" {
|
||||
if let Some(ref tc_id) = m.tool_call_id {
|
||||
pending_tool_ids.remove(tc_id);
|
||||
if pending_tool_ids.is_empty() {
|
||||
pending_assistant_idx = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle trailing assistant with unresolved immediate tool results
|
||||
if !pending_tool_ids.is_empty()
|
||||
&& let Some(idx) = pending_assistant_idx
|
||||
&& !remove_indices.contains(&idx)
|
||||
{
|
||||
if !pending_tool_ids.is_empty() {
|
||||
if let Some(idx) = pending_assistant_idx {
|
||||
if !remove_indices.contains(&idx) {
|
||||
tracing::warn!(
|
||||
message_index = idx,
|
||||
"Removing trailing assistant with incomplete immediate tool results"
|
||||
@ -443,6 +445,8 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
||||
remove_indices.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove in descending index order to avoid shifting.
|
||||
// 两阶段产出的索引并非全局降序:Phase 1(反向扫描)按降序追加,
|
||||
@ -935,7 +939,7 @@ 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_key(|(left, _)| *left);
|
||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
let body = entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
|
||||
@ -234,12 +234,12 @@ impl FeishuChannel {
|
||||
// 1. Check cache
|
||||
{
|
||||
let cached = self.tenant_token.read().await;
|
||||
if let Some(ref token) = *cached
|
||||
&& Instant::now() < token.refresh_after
|
||||
{
|
||||
if let Some(ref token) = *cached {
|
||||
if Instant::now() < token.refresh_after {
|
||||
return Ok(token.value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch new token
|
||||
let (token, ttl) = self.fetch_new_token().await?;
|
||||
@ -1076,11 +1076,11 @@ impl FeishuChannel {
|
||||
.await?;
|
||||
|
||||
// Fetch and prepend quoted message content if this is a reply
|
||||
if let Some(ref pid) = parent_id
|
||||
&& let Some(reply_ctx) = self.get_message_content(pid).await
|
||||
{
|
||||
if let Some(ref pid) = parent_id {
|
||||
if let Some(reply_ctx) = self.get_message_content(pid).await {
|
||||
content = format!("{}\n{}", reply_ctx, content);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(ref m) = media {
|
||||
@ -1532,9 +1532,8 @@ fn parse_post_content(content: &str) -> String {
|
||||
// Fall back: try any dict child
|
||||
if let Some(root_obj) = root.as_object() {
|
||||
for (_key, val) in root_obj {
|
||||
if let Some(obj) = val.as_object()
|
||||
&& obj.get("content").and_then(|c| c.as_array()).is_some()
|
||||
{
|
||||
if let Some(obj) = val.as_object() {
|
||||
if obj.get("content").and_then(|c| c.as_array()).is_some() {
|
||||
parse_block(val, &mut texts);
|
||||
let result = texts.join("");
|
||||
if !result.trim().is_empty() {
|
||||
@ -1544,6 +1543,7 @@ fn parse_post_content(content: &str) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content.to_string()
|
||||
}
|
||||
@ -1565,21 +1565,22 @@ fn extract_interactive_content(content: &str) -> Result<(String, Option<MediaIte
|
||||
}
|
||||
|
||||
// Extract from card object
|
||||
if let Some(card) = parsed.get("card").and_then(|c| c.as_object())
|
||||
&& let Some(elements) = card.get("elements").and_then(|e| e.as_array())
|
||||
{
|
||||
if let Some(card) = parsed.get("card").and_then(|c| c.as_object()) {
|
||||
if let Some(elements) = card.get("elements").and_then(|e| e.as_array()) {
|
||||
for el in elements {
|
||||
extract_element_content(el, &mut texts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from header
|
||||
if let Some(header) = parsed.get("header").and_then(|h| h.as_object())
|
||||
&& let Some(title) = header.get("title").and_then(|t| t.as_object())
|
||||
&& let Some(text) = title.get("content").and_then(|c| c.as_str())
|
||||
{
|
||||
if let Some(header) = parsed.get("header").and_then(|h| h.as_object()) {
|
||||
if let Some(title) = header.get("title").and_then(|t| t.as_object()) {
|
||||
if let Some(text) = title.get("content").and_then(|c| c.as_str()) {
|
||||
texts.push(format!("title: {}\n", text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = texts.join("").trim().to_string();
|
||||
if result.is_empty() {
|
||||
@ -1733,7 +1734,8 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
|
||||
None
|
||||
}
|
||||
})
|
||||
}) && let Some(children) = children_arr
|
||||
}) {
|
||||
if let Some(children) = children_arr
|
||||
.as_object()
|
||||
.and_then(|o| o.get("children"))
|
||||
.and_then(|c| c.as_array())
|
||||
@ -1741,6 +1743,7 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
|
||||
collect_list_items(children, lines, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract text from inline elements (text, link, at-mention)
|
||||
@ -2266,6 +2269,138 @@ fn sanitize_download_file_name(file_name: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FeishuChannel, MsgFormat, extract_file_name_from_content_disposition,
|
||||
infer_download_filename, parse_post_content, sanitize_download_file_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn markdown_post_uses_md_tag() {
|
||||
let content = "**bold**\n1. item1\n2. item2\n[link](https://open.feishu.cn)";
|
||||
let post = FeishuChannel::markdown_to_post(content);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&post).unwrap();
|
||||
|
||||
assert_eq!(parsed["zh_cn"]["content"][0][0]["tag"], "md");
|
||||
assert_eq!(parsed["zh_cn"]["content"][0][0]["text"], content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_markdown_is_not_misclassified_as_plain_post() {
|
||||
let content = "intro\n1. item1\n2. item2";
|
||||
assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Post);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_still_use_interactive() {
|
||||
let content = "intro\n## heading";
|
||||
assert_eq!(
|
||||
FeishuChannel::detect_msg_format(content),
|
||||
MsgFormat::Interactive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_download_filename_prefers_original_file_name() {
|
||||
let content = serde_json::json!({
|
||||
"file_key": "file_key_123",
|
||||
"file_name": "demo-archive.zip"
|
||||
});
|
||||
let headers = reqwest::header::HeaderMap::new();
|
||||
|
||||
let filename =
|
||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||
|
||||
assert_eq!(filename, "om_123_demo-archive.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_download_filename_uses_content_disposition_when_message_lacks_name() {
|
||||
let content = serde_json::json!({
|
||||
"file_key": "file_key_123"
|
||||
});
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_DISPOSITION,
|
||||
reqwest::header::HeaderValue::from_static("attachment; filename=meeting-notes.zip"),
|
||||
);
|
||||
|
||||
let filename =
|
||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||
|
||||
assert_eq!(filename, "om_123_meeting-notes.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_download_filename_falls_back_to_bin_without_name() {
|
||||
let content = serde_json::json!({
|
||||
"file_key": "file_key_123"
|
||||
});
|
||||
let headers = reqwest::header::HeaderMap::new();
|
||||
|
||||
let filename =
|
||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||
|
||||
assert_eq!(filename, "om_123_file_key.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_download_file_name_replaces_path_separators() {
|
||||
let sanitized = sanitize_download_file_name("../../demo/archive.zip");
|
||||
assert_eq!(sanitized, "_.._demo_archive.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_file_name_from_content_disposition_supports_filename_star() {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_DISPOSITION,
|
||||
reqwest::header::HeaderValue::from_static("attachment; filename*=UTF-8''archive.zip"),
|
||||
);
|
||||
|
||||
let file_name = extract_file_name_from_content_disposition(&headers);
|
||||
assert_eq!(file_name.as_deref(), Some("archive.zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_handles_code_block_with_content_array() {
|
||||
// Test parsing code_block with content array (standard Feishu format)
|
||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"python","content":[{"tag":"text","text":"def hello():"},{"tag":"text","text":" print('world')"}]}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```python"));
|
||||
assert!(result.contains("def hello():"));
|
||||
assert!(result.contains("print('world')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_handles_code_block_with_fallback_text() {
|
||||
// Backwards compatibility: some formats might use text field directly
|
||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"rust","text":"fn main() {}"}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```rust"));
|
||||
assert!(result.contains("fn main() {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_handles_code_block_without_language() {
|
||||
// Test code_block without language field
|
||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","content":[{"tag":"text","text":"plain text"}]}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```"));
|
||||
assert!(result.contains("plain text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_handles_empty_code_block() {
|
||||
// Test code_block with empty content
|
||||
let post_json =
|
||||
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```go"));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for FeishuChannel {
|
||||
fn name(&self) -> &str {
|
||||
@ -2367,7 +2502,7 @@ impl Channel for FeishuChannel {
|
||||
let receive_id = if msg.chat_id.starts_with("oc_") {
|
||||
&msg.chat_id
|
||||
} else {
|
||||
msg.reply_to.as_ref().unwrap_or(&msg.chat_id)
|
||||
&msg.reply_to.as_ref().unwrap_or(&msg.chat_id)
|
||||
};
|
||||
let receive_id_type = if msg.chat_id.starts_with("oc_") {
|
||||
"chat_id"
|
||||
@ -2536,135 +2671,3 @@ impl Channel for FeishuChannel {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
FeishuChannel, MsgFormat, extract_file_name_from_content_disposition,
|
||||
infer_download_filename, parse_post_content, sanitize_download_file_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn markdown_post_uses_md_tag() {
|
||||
let content = "**bold**\n1. item1\n2. item2\n[link](https://open.feishu.cn)";
|
||||
let post = FeishuChannel::markdown_to_post(content);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&post).unwrap();
|
||||
|
||||
assert_eq!(parsed["zh_cn"]["content"][0][0]["tag"], "md");
|
||||
assert_eq!(parsed["zh_cn"]["content"][0][0]["text"], content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_markdown_is_not_misclassified_as_plain_post() {
|
||||
let content = "intro\n1. item1\n2. item2";
|
||||
assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Post);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_still_use_interactive() {
|
||||
let content = "intro\n## heading";
|
||||
assert_eq!(
|
||||
FeishuChannel::detect_msg_format(content),
|
||||
MsgFormat::Interactive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_download_filename_prefers_original_file_name() {
|
||||
let content = serde_json::json!({
|
||||
"file_key": "file_key_123",
|
||||
"file_name": "demo-archive.zip"
|
||||
});
|
||||
let headers = reqwest::header::HeaderMap::new();
|
||||
|
||||
let filename =
|
||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||
|
||||
assert_eq!(filename, "om_123_demo-archive.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_download_filename_uses_content_disposition_when_message_lacks_name() {
|
||||
let content = serde_json::json!({
|
||||
"file_key": "file_key_123"
|
||||
});
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_DISPOSITION,
|
||||
reqwest::header::HeaderValue::from_static("attachment; filename=meeting-notes.zip"),
|
||||
);
|
||||
|
||||
let filename =
|
||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||
|
||||
assert_eq!(filename, "om_123_meeting-notes.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_download_filename_falls_back_to_bin_without_name() {
|
||||
let content = serde_json::json!({
|
||||
"file_key": "file_key_123"
|
||||
});
|
||||
let headers = reqwest::header::HeaderMap::new();
|
||||
|
||||
let filename =
|
||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||
|
||||
assert_eq!(filename, "om_123_file_key.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_download_file_name_replaces_path_separators() {
|
||||
let sanitized = sanitize_download_file_name("../../demo/archive.zip");
|
||||
assert_eq!(sanitized, "_.._demo_archive.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_file_name_from_content_disposition_supports_filename_star() {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_DISPOSITION,
|
||||
reqwest::header::HeaderValue::from_static("attachment; filename*=UTF-8''archive.zip"),
|
||||
);
|
||||
|
||||
let file_name = extract_file_name_from_content_disposition(&headers);
|
||||
assert_eq!(file_name.as_deref(), Some("archive.zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_handles_code_block_with_content_array() {
|
||||
// Test parsing code_block with content array (standard Feishu format)
|
||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"python","content":[{"tag":"text","text":"def hello():"},{"tag":"text","text":" print('world')"}]}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```python"));
|
||||
assert!(result.contains("def hello():"));
|
||||
assert!(result.contains("print('world')"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_handles_code_block_with_fallback_text() {
|
||||
// Backwards compatibility: some formats might use text field directly
|
||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"rust","text":"fn main() {}"}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```rust"));
|
||||
assert!(result.contains("fn main() {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_handles_code_block_without_language() {
|
||||
// Test code_block without language field
|
||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","content":[{"tag":"text","text":"plain text"}]}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```"));
|
||||
assert!(result.contains("plain text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_post_content_handles_empty_code_block() {
|
||||
// Test code_block with empty content
|
||||
let post_json =
|
||||
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```go"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,12 +18,6 @@ pub struct ChannelManager {
|
||||
websocket_channel: Arc<CliChannel>,
|
||||
}
|
||||
|
||||
impl Default for ChannelManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelManager {
|
||||
pub fn new() -> Self {
|
||||
let websocket_channel = Arc::new(CliChannel::new());
|
||||
|
||||
@ -69,7 +69,9 @@ impl WechatChannel {
|
||||
let path = media.path.clone();
|
||||
let data = tokio::task::spawn_blocking(move || std::fs::read(&path))
|
||||
.await
|
||||
.map_err(|e| ChannelError::SendError(format!("WeChat media read task failed: {}", e)))?
|
||||
.map_err(|e| {
|
||||
ChannelError::SendError(format!("WeChat media read task failed: {}", e))
|
||||
})?
|
||||
.map_err(|error| {
|
||||
ChannelError::SendError(format!(
|
||||
"WeChat media read failed for '{}': {}",
|
||||
@ -417,9 +419,7 @@ mod tests {
|
||||
std::fs::rename(file.path(), &image_path).unwrap();
|
||||
|
||||
let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image");
|
||||
let content = WechatChannel::media_to_send_content(&media, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let content = WechatChannel::media_to_send_content(&media, None).await.unwrap();
|
||||
|
||||
assert!(matches!(content, SendContent::Image { .. }));
|
||||
}
|
||||
@ -432,9 +432,8 @@ mod tests {
|
||||
std::fs::rename(file.path(), &doc_path).unwrap();
|
||||
|
||||
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
|
||||
let content = WechatChannel::media_to_send_content(&media, Some("note".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
let content =
|
||||
WechatChannel::media_to_send_content(&media, Some("note".to_string())).await.unwrap();
|
||||
|
||||
match content {
|
||||
SendContent::File {
|
||||
|
||||
@ -209,11 +209,11 @@ impl InitWizard {
|
||||
"2" => return self.modify_provider(existing).await,
|
||||
"3" => {
|
||||
println!("Keeping existing providers.");
|
||||
Ok(existing.providers.clone())
|
||||
return Ok(existing.providers.clone());
|
||||
}
|
||||
"4" => {
|
||||
println!("Skipping provider configuration.");
|
||||
Ok(existing.providers.clone())
|
||||
return Ok(existing.providers.clone());
|
||||
}
|
||||
_ => {
|
||||
println!("Invalid option, adding new provider.");
|
||||
@ -378,16 +378,16 @@ impl InitWizard {
|
||||
match choice.as_str() {
|
||||
"1" => {
|
||||
println!("Keeping existing models.");
|
||||
Ok(existing.models.clone())
|
||||
return Ok(existing.models.clone());
|
||||
}
|
||||
"2" => return self.add_model(existing).await,
|
||||
"3" => {
|
||||
println!("Skipping model configuration.");
|
||||
Ok(existing.models.clone())
|
||||
return Ok(existing.models.clone());
|
||||
}
|
||||
_ => {
|
||||
println!("Invalid option, keeping existing models.");
|
||||
Ok(existing.models.clone())
|
||||
return Ok(existing.models.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -505,11 +505,11 @@ impl InitWizard {
|
||||
"2" => return self.modify_agent(existing, providers, models).await,
|
||||
"3" => {
|
||||
println!("Keeping existing agents.");
|
||||
Ok(existing.agents.clone())
|
||||
return Ok(existing.agents.clone());
|
||||
}
|
||||
"4" => {
|
||||
println!("Skipping agent configuration.");
|
||||
Ok(existing.agents.clone())
|
||||
return Ok(existing.agents.clone());
|
||||
}
|
||||
_ => {
|
||||
println!("Invalid option, adding new agent.");
|
||||
|
||||
@ -42,11 +42,12 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let text = text.to_string();
|
||||
if let Ok(outbound) = parse_message(&text) {
|
||||
match outbound {
|
||||
WsOutbound::AssistantResponse { id, content, .. }
|
||||
WsOutbound::AssistantResponse { id, content, .. } => {
|
||||
// Skip if already fully streamed via StreamDelta
|
||||
if !streamed_message_ids.remove(&id) => {
|
||||
if !streamed_message_ids.remove(&id) {
|
||||
input.write_response(&content).await?;
|
||||
}
|
||||
}
|
||||
WsOutbound::ToolCall { tool_name, arguments, .. } => {
|
||||
input.write_output(&format!("Tool call: {}\n{}\n", tool_name, format_json(&arguments))).await?;
|
||||
}
|
||||
@ -234,14 +235,15 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
chat_id: current_session_id.clone(),
|
||||
sender_id: None,
|
||||
};
|
||||
if let Ok(text) = serialize_inbound(&inbound)
|
||||
&& sender.send(Message::Text(text.into())).await.is_err() {
|
||||
if let Ok(text) = serialize_inbound(&inbound) {
|
||||
if sender.send(Message::Text(text.into())).await.is_err() {
|
||||
tracing::error!("Failed to send message to gateway");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Input error");
|
||||
|
||||
@ -138,10 +138,10 @@ async fn handle_get_current_session(
|
||||
.with_message(MessageKind::Notification, &message)
|
||||
.with_metadata("topic_id", &topic.id)
|
||||
.with_metadata("title", &topic.title)
|
||||
.with_metadata("message_count", actual_message_count.to_string())
|
||||
.with_metadata("estimated_tokens", total_tokens.to_string())
|
||||
.with_metadata("system_prompt_tokens", system_prompt_tokens.to_string())
|
||||
.with_metadata("message_tokens", message_tokens.to_string()))
|
||||
.with_metadata("message_count", &actual_message_count.to_string())
|
||||
.with_metadata("estimated_tokens", &total_tokens.to_string())
|
||||
.with_metadata("system_prompt_tokens", &system_prompt_tokens.to_string())
|
||||
.with_metadata("message_tokens", &message_tokens.to_string()))
|
||||
}
|
||||
|
||||
fn format_time_ago(timestamp_ms: i64) -> String {
|
||||
|
||||
@ -57,5 +57,5 @@ async fn handle_list_channels(
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, &message)
|
||||
.with_metadata("channels", &channels_json)
|
||||
.with_metadata("count", channels.len().to_string()))
|
||||
.with_metadata("count", &channels.len().to_string()))
|
||||
}
|
||||
|
||||
@ -85,12 +85,12 @@ async fn handle_list_sessions(
|
||||
));
|
||||
|
||||
// 显示描述(如果有)
|
||||
if let Some(ref desc) = topic.description
|
||||
&& !desc.is_empty()
|
||||
{
|
||||
if let Some(ref desc) = topic.description {
|
||||
if !desc.is_empty() {
|
||||
lines.push(format!(" {}", desc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push("* = current topic".to_string());
|
||||
@ -105,6 +105,6 @@ async fn handle_list_sessions(
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, &message)
|
||||
.with_metadata("topics", &topics_json)
|
||||
.with_metadata("count", topics.len().to_string())
|
||||
.with_metadata("count", &topics.len().to_string())
|
||||
.with_metadata("current_topic_id", current_topic_id))
|
||||
}
|
||||
|
||||
@ -84,5 +84,5 @@ async fn handle_list_sessions_by_channel(
|
||||
.with_message(MessageKind::Notification, &message)
|
||||
.with_metadata("sessions", &sessions_json)
|
||||
.with_metadata("channel_name", &channel_name)
|
||||
.with_metadata("count", summaries.len().to_string()))
|
||||
.with_metadata("count", &summaries.len().to_string()))
|
||||
}
|
||||
|
||||
@ -159,5 +159,5 @@ async fn handle_list_topics(
|
||||
.with_message(MessageKind::Notification, &message)
|
||||
.with_metadata("topics", &topics_json)
|
||||
.with_metadata("session_id", &session_id)
|
||||
.with_metadata("count", summaries.len().to_string()))
|
||||
.with_metadata("count", &summaries.len().to_string()))
|
||||
}
|
||||
|
||||
@ -197,13 +197,13 @@ fn reconstruct_task_from_db(
|
||||
/// New format: "Subagent [type]: description"
|
||||
/// Legacy format: "Subagent: description" (defaults to "general")
|
||||
fn parse_subagent_title(title: &str) -> (String, String) {
|
||||
if let Some(rest) = title.strip_prefix("Subagent [")
|
||||
&& let Some(bracket_pos) = rest.find("]: ")
|
||||
{
|
||||
if let Some(rest) = title.strip_prefix("Subagent [") {
|
||||
if let Some(bracket_pos) = rest.find("]: ") {
|
||||
let agent_type = rest[..bracket_pos].to_string();
|
||||
let desc = rest[bracket_pos + 3..].to_string();
|
||||
return (agent_type, desc);
|
||||
}
|
||||
}
|
||||
let desc = title
|
||||
.strip_prefix("Subagent: ")
|
||||
.unwrap_or(title)
|
||||
|
||||
@ -60,5 +60,5 @@ async fn handle_load_topic(
|
||||
.with_message(MessageKind::Notification, &topic.title)
|
||||
.with_metadata("topic_id", &topic.id)
|
||||
.with_metadata("title", &topic.title)
|
||||
.with_metadata("message_count", topic.message_count.to_string()))
|
||||
.with_metadata("message_count", &topic.message_count.to_string()))
|
||||
}
|
||||
|
||||
@ -95,7 +95,7 @@ async fn handle_rename_topic(
|
||||
return Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(
|
||||
MessageKind::Notification,
|
||||
format!("✓ 话题标题未变化: {}", trimmed_title),
|
||||
&format!("✓ 话题标题未变化: {}", trimmed_title),
|
||||
)
|
||||
.with_metadata("topics", &topic_summaries_json)
|
||||
.with_metadata("topic_id", &topic_id)
|
||||
|
||||
@ -72,13 +72,12 @@ pub async fn save_session_to_file(
|
||||
let output_path = resolve_filepath(filepath, &record);
|
||||
|
||||
// 创建父目录
|
||||
if let Some(parent) = output_path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
&& !parent.exists()
|
||||
{
|
||||
if let Some(parent) = output_path.parent() {
|
||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
@ -193,7 +192,7 @@ async fn handle_save_session(
|
||||
filepath,
|
||||
include_all,
|
||||
include_subagents,
|
||||
&handler.store,
|
||||
&*handler.store,
|
||||
Some(handler.task_repository.as_ref()),
|
||||
&*handler.system_prompt_provider,
|
||||
)
|
||||
@ -214,16 +213,16 @@ async fn handle_save_session(
|
||||
MessageKind::Notification,
|
||||
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
||||
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
||||
format!(
|
||||
&format!(
|
||||
"Session saved to: {}",
|
||||
output_path.display().to_string().replace('\\', "/")
|
||||
),
|
||||
)
|
||||
.with_metadata(
|
||||
"filepath",
|
||||
output_path.display().to_string().replace('\\', "/"),
|
||||
&output_path.display().to_string().replace('\\', "/"),
|
||||
)
|
||||
.with_metadata("message_count", message_count.to_string()))
|
||||
.with_metadata("message_count", &message_count.to_string()))
|
||||
}
|
||||
|
||||
/// 子智能体任务数据
|
||||
@ -392,9 +391,8 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
if let Some(ref calls) = msg.tool_calls
|
||||
&& !calls.is_empty()
|
||||
{
|
||||
if let Some(ref calls) = msg.tool_calls {
|
||||
if !calls.is_empty() {
|
||||
output.push_str("**Tool Calls:**\n\n");
|
||||
for call in calls {
|
||||
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
|
||||
@ -408,6 +406,7 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
output.push_str("---\n\n");
|
||||
}
|
||||
@ -561,9 +560,8 @@ pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> Strin
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if let Some(ref calls) = msg.tool_calls
|
||||
&& !calls.is_empty()
|
||||
{
|
||||
if let Some(ref calls) = msg.tool_calls {
|
||||
if !calls.is_empty() {
|
||||
output.push_str("### Tool Calls\n\n");
|
||||
for call in calls {
|
||||
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
|
||||
@ -577,6 +575,7 @@ pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> Strin
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Media refs
|
||||
if !msg.media_refs.is_empty() {
|
||||
@ -622,7 +621,16 @@ pub fn resolve_filepath(filepath: Option<String>, record: &SessionRecord) -> Pat
|
||||
// 生成安全标题(替换特殊字符)
|
||||
let safe_title = record
|
||||
.title
|
||||
.replace([' ', '/', '\\', ':', '<', '>', '|', '?', '*', '"'], "_");
|
||||
.replace(' ', "_")
|
||||
.replace('/', "_")
|
||||
.replace('\\', "_")
|
||||
.replace(':', "_")
|
||||
.replace('<', "_")
|
||||
.replace('>', "_")
|
||||
.replace('|', "_")
|
||||
.replace('?', "_")
|
||||
.replace('*', "_")
|
||||
.replace('"', "_");
|
||||
|
||||
// 使用标题或 session_id 作为文件名
|
||||
let base_name = if safe_title.is_empty() {
|
||||
@ -708,7 +716,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
|
||||
filepath,
|
||||
include_all,
|
||||
include_subagents,
|
||||
&self.store,
|
||||
&*self.store,
|
||||
Some(self.task_repository.as_ref()),
|
||||
&*self.system_prompt_provider,
|
||||
)
|
||||
|
||||
@ -54,13 +54,12 @@ pub async fn save_topic_to_file(
|
||||
let output_path = resolve_topic_filepath(filepath, &topic);
|
||||
|
||||
// 创建父目录
|
||||
if let Some(parent) = output_path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
&& !parent.exists()
|
||||
{
|
||||
if let Some(parent) = output_path.parent() {
|
||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
@ -139,7 +138,16 @@ fn resolve_topic_filepath(filepath: Option<String>, topic: &TopicRecord) -> Path
|
||||
None => {
|
||||
let safe_title = topic
|
||||
.title
|
||||
.replace([' ', '/', '\\', ':', '<', '>', '|', '?', '*', '"'], "_");
|
||||
.replace(' ', "_")
|
||||
.replace('/', "_")
|
||||
.replace('\\', "_")
|
||||
.replace(':', "_")
|
||||
.replace('<', "_")
|
||||
.replace('>', "_")
|
||||
.replace('|', "_")
|
||||
.replace('?', "_")
|
||||
.replace('*', "_")
|
||||
.replace('"', "_");
|
||||
|
||||
let base_name = if safe_title.is_empty() {
|
||||
format!("topic_{}", &topic.id[..8.min(topic.id.len())])
|
||||
@ -259,7 +267,7 @@ async fn handle_save_topic(
|
||||
topic_id,
|
||||
filepath,
|
||||
include_subagents,
|
||||
&handler.store,
|
||||
&*handler.store,
|
||||
Some(handler.task_repository.as_ref()),
|
||||
&*handler.system_prompt_provider,
|
||||
&messages,
|
||||
@ -274,14 +282,14 @@ async fn handle_save_topic(
|
||||
MessageKind::Notification,
|
||||
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
||||
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
||||
format!(
|
||||
&format!(
|
||||
"Topic saved to: {}",
|
||||
output_path.display().to_string().replace('\\', "/")
|
||||
),
|
||||
)
|
||||
.with_metadata(
|
||||
"filepath",
|
||||
output_path.display().to_string().replace('\\', "/"),
|
||||
&output_path.display().to_string().replace('\\', "/"),
|
||||
)
|
||||
.with_metadata("message_count", message_count.to_string()))
|
||||
.with_metadata("message_count", &message_count.to_string()))
|
||||
}
|
||||
|
||||
@ -94,14 +94,14 @@ async fn handle_create_session(
|
||||
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
|
||||
|
||||
// 如果有 SessionManager,自动切换到新话题
|
||||
if let Some(ref session_manager) = handler.session_manager
|
||||
&& let Some(session) = session_manager.get(&ctx.channel_name).await
|
||||
{
|
||||
if let Some(ref session_manager) = handler.session_manager {
|
||||
if let Some(session) = session_manager.get(&ctx.channel_name).await {
|
||||
let mut session_guard = session.lock().await;
|
||||
session_guard
|
||||
.switch_topic(chat_id, &topic.id)
|
||||
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Query the full topic list so the frontend sidebar can update
|
||||
let topics = handler
|
||||
@ -119,7 +119,7 @@ async fn handle_create_session(
|
||||
.with_metadata("topics", &topics_json)
|
||||
.with_metadata("topic_id", &topic.id)
|
||||
.with_metadata("session_id", &topic.session_id)
|
||||
.with_metadata("message_count", topic.message_count.to_string()))
|
||||
.with_metadata("message_count", &topic.message_count.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -108,7 +108,10 @@ impl CommandHandler for StopExecutionCommandHandler {
|
||||
|
||||
if cancelled || cancelled_subagents > 0 {
|
||||
let msg = if cancelled && cancelled_subagents > 0 {
|
||||
format!("正在停止当前任务及 {} 个后台子代理...", cancelled_subagents)
|
||||
format!(
|
||||
"正在停止当前任务及 {} 个后台子代理...",
|
||||
cancelled_subagents
|
||||
)
|
||||
} else if cancelled {
|
||||
"正在停止当前任务...".to_string()
|
||||
} else {
|
||||
|
||||
@ -103,14 +103,14 @@ async fn handle_switch_topic(
|
||||
})?;
|
||||
|
||||
// 如果有 SessionManager,实际切换话题历史
|
||||
if let Some(ref session_manager) = handler.session_manager
|
||||
&& let Some(session) = session_manager.get(&ctx.channel_name).await
|
||||
{
|
||||
if let Some(ref session_manager) = handler.session_manager {
|
||||
if let Some(session) = session_manager.get(&ctx.channel_name).await {
|
||||
let mut session_guard = session.lock().await;
|
||||
session_guard
|
||||
.switch_topic(chat_id, &target_topic_id)
|
||||
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用辅助方法获取消息数量
|
||||
let msg_count = handler
|
||||
@ -127,5 +127,5 @@ async fn handle_switch_topic(
|
||||
.with_message(MessageKind::Notification, &message)
|
||||
.with_metadata("topic_id", &topic.id)
|
||||
.with_metadata("title", &topic.title)
|
||||
.with_metadata("message_count", msg_count.to_string()))
|
||||
.with_metadata("message_count", &msg_count.to_string()))
|
||||
}
|
||||
|
||||
@ -128,7 +128,7 @@ impl Default for CompactionConfig {
|
||||
}
|
||||
|
||||
/// 可观测性配置(日志格式、metrics 开关等)
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ObservabilityConfig {
|
||||
/// 日志输出格式:text(默认)或 json。
|
||||
/// json 格式便于接入 ELK/Loki 等日志聚合系统。
|
||||
@ -136,6 +136,14 @@ pub struct ObservabilityConfig {
|
||||
pub log_format: LogFormat,
|
||||
}
|
||||
|
||||
impl Default for ObservabilityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_format: LogFormat::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志输出格式
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@ -623,11 +631,6 @@ pub struct GatewayConfig {
|
||||
/// 前端通过 Authorization: Bearer <token> 头或 WS query param ?token=<token> 携带。
|
||||
#[serde(default, rename = "auth_token")]
|
||||
pub auth_token: Option<String>,
|
||||
/// 远程部署时允许的跨域来源白名单(如 ["https://bot.example.com"])。
|
||||
/// 绑定非 loopback 时生效:配置后 CORS 仅放行列出的 origin;
|
||||
/// 未配置则允许任意 origin(此时由 auth_token 提供保护,启动时会打 warn 日志提示加固)。
|
||||
#[serde(default, rename = "allowed_origins")]
|
||||
pub allowed_origins: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@ -938,7 +941,6 @@ impl Default for GatewayConfig {
|
||||
max_concurrent_requests: default_max_concurrent_requests(),
|
||||
session_ttl_hours: Some(24),
|
||||
auth_token: None,
|
||||
allowed_origins: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2303,33 +2305,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_schedule_validation_rejects_invalid_values() {
|
||||
assert!(
|
||||
SchedulerSchedule::Delay { seconds: 0 }
|
||||
assert!(SchedulerSchedule::Delay { seconds: 0 }
|
||||
.validate("delay.job")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
SchedulerSchedule::Interval {
|
||||
.is_err());
|
||||
assert!(SchedulerSchedule::Interval {
|
||||
seconds: 0,
|
||||
startup_delay_secs: 0,
|
||||
}
|
||||
.validate("interval.job")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
SchedulerSchedule::At {
|
||||
.is_err());
|
||||
assert!(SchedulerSchedule::At {
|
||||
timestamp: "bad timestamp".to_string(),
|
||||
}
|
||||
.validate("at.job")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
SchedulerSchedule::Cron {
|
||||
.is_err());
|
||||
assert!(SchedulerSchedule::Cron {
|
||||
expression: "bad cron".to_string(),
|
||||
}
|
||||
.validate("cron.job")
|
||||
.is_err()
|
||||
);
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -63,14 +63,14 @@ impl CapabilityPolicy {
|
||||
|
||||
/// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。
|
||||
pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> {
|
||||
if let Some(list) = &self.allowed_subagents
|
||||
&& !list.iter().any(|s| s == name)
|
||||
{
|
||||
if let Some(list) = &self.allowed_subagents {
|
||||
if !list.iter().any(|s| s == name) {
|
||||
return Err(format!(
|
||||
"subagent '{}' is not in the allowed_subagents whitelist",
|
||||
name
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.denied_subagents.iter().any(|s| s == name) {
|
||||
return Err(format!(
|
||||
"subagent '{}' is in the denied_subagents blacklist",
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
use crate::config::ExpertsConfig;
|
||||
use crate::domain::CapabilityPolicy;
|
||||
use crate::platform::{atomic_rename, home_dir as platform_home_dir};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
#[cfg(test)]
|
||||
static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
@ -294,13 +294,18 @@ impl ExpertRuntime {
|
||||
|
||||
/// Re-discover experts from the filesystem.
|
||||
pub fn reload(&self) -> Result<ExpertCatalog, String> {
|
||||
let config = self.config.read().clone();
|
||||
let config = self
|
||||
.config
|
||||
.read()
|
||||
.clone();
|
||||
let catalog = ExpertCatalog::discover_with_state(
|
||||
&config,
|
||||
&self.cwd,
|
||||
Some(&load_expert_disable_state(&self.cwd)),
|
||||
);
|
||||
let mut guard = self.catalog.write();
|
||||
let mut guard = self
|
||||
.catalog
|
||||
.write();
|
||||
*guard = catalog.clone();
|
||||
Ok(catalog)
|
||||
}
|
||||
@ -318,12 +323,18 @@ impl ExpertRuntime {
|
||||
|
||||
/// List enabled experts (disabled ones are filtered out).
|
||||
pub fn list_experts(&self) -> Vec<Expert> {
|
||||
self.catalog.read().experts.clone()
|
||||
self.catalog
|
||||
.read()
|
||||
.experts
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// List all discovered experts including disabled ones, with their disabled scopes.
|
||||
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
|
||||
let config = self.config.read().clone();
|
||||
let config = self
|
||||
.config
|
||||
.read()
|
||||
.clone();
|
||||
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
|
||||
let disable_state = load_expert_disable_state(&self.cwd);
|
||||
|
||||
@ -350,7 +361,10 @@ impl ExpertRuntime {
|
||||
}
|
||||
|
||||
pub fn get_expert(&self, name: &str) -> Option<Expert> {
|
||||
self.catalog.read().find_expert(name).cloned()
|
||||
self.catalog
|
||||
.read()
|
||||
.find_expert(name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn create_expert(
|
||||
@ -460,7 +474,10 @@ impl ExpertRuntime {
|
||||
|
||||
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
|
||||
validate_expert_name(name)?;
|
||||
let config = self.config.read().clone();
|
||||
let config = self
|
||||
.config
|
||||
.read()
|
||||
.clone();
|
||||
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
|
||||
Ok(catalog.find_expert(name).is_some())
|
||||
}
|
||||
@ -492,7 +509,9 @@ impl ExpertRuntime {
|
||||
|
||||
// update in-memory disable_state
|
||||
{
|
||||
let mut state = self.disable_state.write();
|
||||
let mut state = self
|
||||
.disable_state
|
||||
.write();
|
||||
match scope {
|
||||
ExpertScope::User => {
|
||||
if enabled {
|
||||
@ -514,7 +533,9 @@ impl ExpertRuntime {
|
||||
// refresh catalog so list_experts / get_expert reflect the change
|
||||
let _ = self.reload()?;
|
||||
|
||||
let state = self.disable_state.read();
|
||||
let state = self
|
||||
.disable_state
|
||||
.read();
|
||||
let disabled_in_scopes = state.disabled_scopes_for(name);
|
||||
|
||||
Ok(ExpertAvailabilityChange {
|
||||
@ -537,7 +558,9 @@ impl ExpertRuntime {
|
||||
}
|
||||
|
||||
{
|
||||
let mut sessions = self.session_experts.write();
|
||||
let mut sessions = self
|
||||
.session_experts
|
||||
.write();
|
||||
sessions.insert(session_id.to_string(), expert_name.to_string());
|
||||
}
|
||||
persist_session_experts(&self.cwd, |state| {
|
||||
@ -550,7 +573,9 @@ impl ExpertRuntime {
|
||||
/// Clear the selected expert for a session.
|
||||
pub fn clear_expert(&self, session_id: &str) -> Result<(), String> {
|
||||
{
|
||||
let mut sessions = self.session_experts.write();
|
||||
let mut sessions = self
|
||||
.session_experts
|
||||
.write();
|
||||
sessions.remove(session_id);
|
||||
}
|
||||
persist_session_experts(&self.cwd, |state| {
|
||||
@ -561,12 +586,16 @@ impl ExpertRuntime {
|
||||
/// Returns the expert selected for a session, or None if none selected / disabled / not found.
|
||||
pub fn selected_expert_for(&self, session_id: &str) -> Option<Expert> {
|
||||
let name = {
|
||||
let sessions = self.session_experts.read();
|
||||
let sessions = self
|
||||
.session_experts
|
||||
.read();
|
||||
sessions.get(session_id).cloned()
|
||||
}?;
|
||||
|
||||
// Filter out disabled experts.
|
||||
let state = self.disable_state.read();
|
||||
let state = self
|
||||
.disable_state
|
||||
.read();
|
||||
if state.is_disabled(&name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@ -3,9 +3,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::agent::context_compressor::ContextCompressor;
|
||||
use crate::agent::{
|
||||
AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider,
|
||||
};
|
||||
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider};
|
||||
use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver};
|
||||
use crate::domain::CapabilityPolicy;
|
||||
use crate::experts::ExpertPromptProvider;
|
||||
@ -16,10 +14,10 @@ use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
||||
use crate::observability::Observer;
|
||||
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
||||
use crate::storage::PromptInjectionRepository;
|
||||
use crate::storage::SessionStore;
|
||||
use crate::storage::persistent_session_id;
|
||||
use crate::tools::task::SubagentResult;
|
||||
use crate::storage::SessionStore;
|
||||
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
|
||||
use crate::tools::task::SubagentResult;
|
||||
use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator};
|
||||
|
||||
/// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。
|
||||
@ -135,10 +133,7 @@ impl AgentFactory {
|
||||
/// 构造 ContextCompressor(参数内聚到 ContextCompressor,CompactionConfig 注入)。
|
||||
/// AgentLoop(in-loop 压缩)和 Session(sync 兜底压缩)共用此方法,
|
||||
/// 确保两条压缩路径使用同一套用户配置的压缩参数。
|
||||
pub(crate) fn build_compressor(
|
||||
&self,
|
||||
runtime_config: &AgentRuntimeConfig,
|
||||
) -> ContextCompressor {
|
||||
pub(crate) fn build_compressor(&self, runtime_config: &AgentRuntimeConfig) -> ContextCompressor {
|
||||
ContextCompressor::with_compaction_config(
|
||||
runtime_config.context_window_tokens,
|
||||
runtime_config.context_summary_char_budget,
|
||||
@ -206,17 +201,14 @@ impl AgentFactory {
|
||||
|
||||
// 物化:命中 session 级选择且话题无固化值时,将解析后的具体
|
||||
// (provider, model) 写入 topics 行(持久化 + 内存缓存)
|
||||
if !from_topic && let Some(tid) = request.topic_id.as_deref() {
|
||||
if !from_topic {
|
||||
if let Some(tid) = request.topic_id.as_deref() {
|
||||
let provider = resolved.name.clone();
|
||||
let model = resolved.model_id.clone();
|
||||
self.topic_model_selections.set(
|
||||
tid,
|
||||
Some(provider.clone()),
|
||||
Some(model.clone()),
|
||||
);
|
||||
self.topic_model_selections
|
||||
.set(tid, Some(provider.clone()), Some(model.clone()));
|
||||
if let Err(err) =
|
||||
self.store
|
||||
.update_topic_model(tid, Some(&provider), Some(&model))
|
||||
self.store.update_topic_model(tid, Some(&provider), Some(&model))
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
@ -225,6 +217,7 @@ impl AgentFactory {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
instance_id = self.instance_id,
|
||||
@ -296,7 +289,7 @@ impl AgentFactory {
|
||||
// 供 wait_for_subagents 工具传递给 coordinator.wait() 的 select!。
|
||||
// watch::Receiver::clone() 创建共享同一 sender 的新 receiver,
|
||||
// 各 receiver 的 has_changed()/changed() 状态独立,互不影响。
|
||||
let cancel_rx_for_context = request.cancel_token.clone();
|
||||
let cancel_rx_for_context = request.cancel_token.as_ref().map(|rx| rx.clone());
|
||||
|
||||
let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
|
||||
let compressor = Arc::new(self.build_compressor(&runtime_config));
|
||||
|
||||
@ -42,9 +42,8 @@ impl AgentPromptProvider {
|
||||
|
||||
/// 记录注入事件
|
||||
fn record_injection(&self, context: &SystemPromptContext) {
|
||||
if let Some(session_id) = &context.session_id
|
||||
&& let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id)
|
||||
{
|
||||
if let Some(session_id) = &context.session_id {
|
||||
if let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id) {
|
||||
tracing::warn!(
|
||||
session_id = ?session_id,
|
||||
error = %e,
|
||||
@ -52,6 +51,7 @@ impl AgentPromptProvider {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemPromptProvider for AgentPromptProvider {
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
//! 网关认证与访问控制。
|
||||
//!
|
||||
//! 设计目标(第一性原理):
|
||||
//! - 本地单机部署(host 为 loopback):免认证,靠 Host 头 loopback 校验(防 DNS rebinding)
|
||||
//! + CORS loopback origin 白名单(防跨域读取)+ WS Origin loopback 校验(防 CSWSH)。
|
||||
//! - 本地单机部署(host 为 loopback):免认证,仅靠 CORS 防御 DNS rebinding / CSRF。
|
||||
//! - 远程访问(host 非 loopback):必须配置 `auth_token`,所有 `/api/*` 与 `/ws` 强制校验。
|
||||
//! - token 通过 `Authorization: Bearer <token>`(HTTP)或 `?token=<token>`(WS)传递。
|
||||
//! - 校验使用常量时间比较,避免计时侧信道。
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::Request;
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
@ -89,7 +88,11 @@ pub fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
/// 仅在 `requires_auth` 为 true 时挂载。
|
||||
/// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。
|
||||
/// `/metrics` 包含运行时指标(provider/model/耗时/token 用量),远程部署时需保护。
|
||||
pub async fn require_bearer_auth(headers: HeaderMap, request: Request, next: Next) -> Response {
|
||||
pub async fn require_bearer_auth(
|
||||
headers: HeaderMap,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = request.uri().path();
|
||||
|
||||
// /api/* 和 /metrics 需要认证;其余放行
|
||||
@ -123,85 +126,6 @@ pub struct AuthConfig {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// 提取 Origin 头的 authority(host[:port])部分。
|
||||
/// 格式如 `https://bot.example.com:8443/path` -> `bot.example.com:8443`。
|
||||
/// 无法解析(如隐私上下文下的 `Origin: null`)时返回 None。
|
||||
pub(crate) fn origin_authority(origin: &str) -> Option<&str> {
|
||||
let rest = origin.split_once("://").map(|(_, r)| r)?;
|
||||
let authority = rest.split(['/', '?', '#']).next()?.trim();
|
||||
if authority.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(authority)
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定 `host[:port]` 形式的主机值是否为 loopback(供 Host 头 / Origin authority 复用)。
|
||||
/// - `127.0.0.1:19876` / `localhost:19876` -> true
|
||||
/// - `[::1]:19876` -> true
|
||||
/// - `evil.com:19876` -> false
|
||||
/// - 无括号的裸 IPv6(如 `::1`)按最后一个冒号拆分,行为保守(浏览器 Host 头恒带括号)
|
||||
pub fn host_port_is_loopback(host_value: &str) -> bool {
|
||||
let host_value = host_value.trim();
|
||||
let host_part = if let Some(rest) = host_value.strip_prefix('[') {
|
||||
match rest.split_once(']') {
|
||||
Some((inner, _)) => inner,
|
||||
None => return false,
|
||||
}
|
||||
} else {
|
||||
match host_value.rsplit_once(':') {
|
||||
Some((h, port_like)) if port_like.chars().all(|c| c.is_ascii_digit()) => h,
|
||||
_ => host_value,
|
||||
}
|
||||
};
|
||||
is_loopback_host(host_part)
|
||||
}
|
||||
|
||||
/// 判定 Origin 值是否为 loopback 来源(authority 的主机部分属于 loopback 段,端口不限)。
|
||||
/// `Origin: null` 或无法解析时返回 false。
|
||||
pub fn origin_is_loopback(origin: &str) -> bool {
|
||||
origin_authority(origin.trim())
|
||||
.map(host_port_is_loopback)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 校验 WebSocket 升级请求的 Origin(防跨站 WebSocket 劫持 CSWSH)。
|
||||
/// 仅在无 token 认证保护时(loopback 免认证模式)调用:
|
||||
///
|
||||
/// - 无 Origin 头:放行(非浏览器客户端不发送 Origin)
|
||||
/// - Origin 为 loopback 来源:放行(覆盖同源、localhost↔127.0.0.1、vite dev 等合法场景)
|
||||
/// - 其余(公网域名、`Origin: null`):拒绝。
|
||||
///
|
||||
/// 注意不能用 Origin==Host 相等判定:DNS rebinding 下两者一致,
|
||||
/// 而合法开发场景中两者常常不同(localhost:5173 → 127.0.0.1:19876)。
|
||||
pub fn ws_origin_loopback(headers: &HeaderMap) -> bool {
|
||||
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
|
||||
return true;
|
||||
};
|
||||
origin_is_loopback(origin)
|
||||
}
|
||||
|
||||
/// axum 中间件:强制 Host 头为 loopback(仅在 loopback 免认证模式下挂载)。
|
||||
/// 阻断 DNS rebinding:重绑定后浏览器发送的 Host 为攻击者域名,直接拒绝。
|
||||
/// 无 Host 头的请求同样拒绝(浏览器恒发送 Host)。
|
||||
pub async fn require_loopback_host(headers: HeaderMap, request: Request, next: Next) -> Response {
|
||||
let host_ok = headers
|
||||
.get(header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(host_port_is_loopback)
|
||||
.unwrap_or(false);
|
||||
if host_ok {
|
||||
next.run(request).await
|
||||
} else {
|
||||
tracing::warn!("Request rejected: Host header is not loopback (possible DNS rebinding)");
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": "forbidden", "message": "Host header must be loopback" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -318,88 +242,4 @@ mod tests {
|
||||
assert_eq!(extract_bearer_token(&make_auth_header("Bearer")), None);
|
||||
assert_eq!(extract_bearer_token(&make_auth_header("Bearer ")), Some(""));
|
||||
}
|
||||
|
||||
fn make_origin_host_headers(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
if let Some(o) = origin {
|
||||
h.insert(
|
||||
axum::http::header::ORIGIN,
|
||||
axum::http::HeaderValue::from_str(o).unwrap(),
|
||||
);
|
||||
}
|
||||
if let Some(host) = host {
|
||||
h.insert(
|
||||
axum::http::header::HOST,
|
||||
axum::http::HeaderValue::from_str(host).unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_port_loopback_detection() {
|
||||
assert!(host_port_is_loopback("127.0.0.1:19876"));
|
||||
assert!(host_port_is_loopback("127.0.0.1"));
|
||||
assert!(host_port_is_loopback("localhost:19876"));
|
||||
assert!(host_port_is_loopback("[::1]:19876"));
|
||||
assert!(host_port_is_loopback(" localhost:19876 "));
|
||||
assert!(!host_port_is_loopback("evil.com:19876"));
|
||||
assert!(!host_port_is_loopback("evil.com"));
|
||||
assert!(!host_port_is_loopback("192.168.1.1:19876"));
|
||||
assert!(!host_port_is_loopback(""));
|
||||
// 未闭合括号:拒绝
|
||||
assert!(!host_port_is_loopback("[::1:19876"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_loopback_detection() {
|
||||
assert!(origin_is_loopback("http://127.0.0.1:19876"));
|
||||
assert!(origin_is_loopback("http://localhost:5173"));
|
||||
assert!(origin_is_loopback("http://[::1]:3000"));
|
||||
// 公网域名:拒绝
|
||||
assert!(!origin_is_loopback("http://evil.com"));
|
||||
assert!(!origin_is_loopback("https://evil.com:8443/path"));
|
||||
// Origin: null(隐私上下文):拒绝
|
||||
assert!(!origin_is_loopback("null"));
|
||||
assert!(!origin_is_loopback(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_origin_check() {
|
||||
// 无 Origin(非浏览器客户端):放行
|
||||
assert!(ws_origin_loopback(&make_origin_host_headers(
|
||||
None,
|
||||
Some("127.0.0.1:19876")
|
||||
)));
|
||||
// 同源:放行
|
||||
assert!(ws_origin_loopback(&make_origin_host_headers(
|
||||
Some("http://127.0.0.1:19876"),
|
||||
Some("127.0.0.1:19876")
|
||||
)));
|
||||
// vite dev 场景:localhost:5173 → 127.0.0.1:19876,双方都是 loopback:放行
|
||||
assert!(ws_origin_loopback(&make_origin_host_headers(
|
||||
Some("http://localhost:5173"),
|
||||
Some("127.0.0.1:19876")
|
||||
)));
|
||||
// localhost ↔ 127.0.0.1 混用:放行
|
||||
assert!(ws_origin_loopback(&make_origin_host_headers(
|
||||
Some("http://localhost:19876"),
|
||||
Some("127.0.0.1:19876")
|
||||
)));
|
||||
// 恶意页面直连 127.0.0.1:Origin 为公网域名,拒绝
|
||||
assert!(!ws_origin_loopback(&make_origin_host_headers(
|
||||
Some("http://evil.com"),
|
||||
Some("127.0.0.1:19876")
|
||||
)));
|
||||
// DNS rebinding:Host/Origin 同为攻击者域名,拒绝
|
||||
assert!(!ws_origin_loopback(&make_origin_host_headers(
|
||||
Some("http://evil.com:19876"),
|
||||
Some("evil.com:19876")
|
||||
)));
|
||||
// Origin: null:拒绝
|
||||
assert!(!ws_origin_loopback(&make_origin_host_headers(
|
||||
Some("null"),
|
||||
Some("127.0.0.1:19876")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,7 +155,7 @@ impl AgentExecutionService {
|
||||
// 直接比较 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) == Some(orig_tid),
|
||||
Some(orig_tid) => session.current_topic(request.chat_id).as_deref() == Some(orig_tid),
|
||||
None => true, // 无 topic 时总是视为当前回合
|
||||
};
|
||||
|
||||
@ -419,11 +419,7 @@ impl AgentExecutionService {
|
||||
);
|
||||
|
||||
let result = agent
|
||||
.process(
|
||||
history,
|
||||
Some(&system_prompt_context),
|
||||
Some(&compaction_sink),
|
||||
)
|
||||
.process(history, Some(&system_prompt_context), Some(&compaction_sink))
|
||||
.await?;
|
||||
let mut metadata = HashMap::new();
|
||||
// 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID,使 todo 点击跳转能匹配
|
||||
@ -609,11 +605,7 @@ impl AgentExecutionService {
|
||||
);
|
||||
|
||||
let result = agent
|
||||
.process(
|
||||
history,
|
||||
Some(&system_prompt_context),
|
||||
Some(&compaction_sink),
|
||||
)
|
||||
.process(history, Some(&system_prompt_context), Some(&compaction_sink))
|
||||
.await?;
|
||||
|
||||
let outbound_messages = self
|
||||
|
||||
@ -65,20 +65,20 @@ fn mask_config(config: &Config) -> Config {
|
||||
}
|
||||
}
|
||||
for channel in masked.channels.values_mut() {
|
||||
if let Some(feishu) = channel.as_feishu_mut()
|
||||
&& !feishu.app_secret.is_empty()
|
||||
{
|
||||
if let Some(feishu) = channel.as_feishu_mut() {
|
||||
if !feishu.app_secret.is_empty() {
|
||||
let visible: String = feishu.app_secret.chars().take(4).collect();
|
||||
feishu.app_secret = format!("{}{}", visible, API_KEY_MASK);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 掩码网关认证 token(避免通过 /api/config 泄露)
|
||||
if let Some(ref token) = masked.gateway.auth_token
|
||||
&& !token.is_empty()
|
||||
{
|
||||
if let Some(ref token) = masked.gateway.auth_token {
|
||||
if !token.is_empty() {
|
||||
let visible: String = token.chars().take(4).collect();
|
||||
masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK));
|
||||
}
|
||||
}
|
||||
masked
|
||||
}
|
||||
|
||||
@ -116,27 +116,29 @@ pub async fn save_config(
|
||||
{
|
||||
let cfg = state.config.read().await;
|
||||
for (name, provider) in new_config.providers.iter_mut() {
|
||||
if is_masked_key(&provider.api_key)
|
||||
&& let Some(original) = cfg.providers.get(name)
|
||||
{
|
||||
if is_masked_key(&provider.api_key) {
|
||||
if let Some(original) = cfg.providers.get(name) {
|
||||
provider.api_key = original.api_key.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, channel) in new_config.channels.iter_mut() {
|
||||
if let Some(feishu) = channel.as_feishu_mut()
|
||||
&& is_masked_key(&feishu.app_secret)
|
||||
&& let Some(original_channel) = cfg.channels.get(name)
|
||||
&& let Some(original_feishu) = original_channel.as_feishu()
|
||||
{
|
||||
if let Some(feishu) = channel.as_feishu_mut() {
|
||||
if is_masked_key(&feishu.app_secret) {
|
||||
if let Some(original_channel) = cfg.channels.get(name) {
|
||||
if let Some(original_feishu) = original_channel.as_feishu() {
|
||||
feishu.app_secret = original_feishu.app_secret.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 保留原始 auth_token(若提交的是掩码值)
|
||||
if let Some(ref submitted) = new_config.gateway.auth_token
|
||||
&& is_masked_key(submitted)
|
||||
{
|
||||
if let Some(ref submitted) = new_config.gateway.auth_token {
|
||||
if is_masked_key(submitted) {
|
||||
new_config.gateway.auth_token = cfg.gateway.auth_token.clone();
|
||||
}
|
||||
}
|
||||
} // read lock released here
|
||||
|
||||
// Validate timezone
|
||||
@ -241,7 +243,9 @@ pub async fn list_executions(State(state): State<Arc<GatewayState>>) -> Json<Exe
|
||||
/// GET /metrics — Prometheus metrics 端点
|
||||
///
|
||||
/// 返回 Prometheus 格式的 metrics 文本。若 recorder 未安装则返回 503。
|
||||
pub async fn metrics_handler(State(state): State<Arc<GatewayState>>) -> (StatusCode, String) {
|
||||
pub async fn metrics_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> (StatusCode, String) {
|
||||
match &state.prometheus_handle {
|
||||
Some(handle) => (StatusCode::OK, handle.render()),
|
||||
None => (
|
||||
@ -1156,9 +1160,8 @@ pub async fn session_select_model(
|
||||
// 校验:provider/model 名必须在 config 的 providers/models 表中存在
|
||||
// (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误)
|
||||
let config = state.config.read().await;
|
||||
if let Some(name) = provider.as_ref()
|
||||
&& !config.providers.contains_key(name)
|
||||
{
|
||||
if let Some(name) = provider.as_ref() {
|
||||
if !config.providers.contains_key(name) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(SelectModelResponse {
|
||||
@ -1167,9 +1170,9 @@ pub async fn session_select_model(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(name) = model.as_ref()
|
||||
&& !config.models.contains_key(name)
|
||||
{
|
||||
}
|
||||
if let Some(name) = model.as_ref() {
|
||||
if !config.models.contains_key(name) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(SelectModelResponse {
|
||||
@ -1178,6 +1181,7 @@ pub async fn session_select_model(
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(config);
|
||||
|
||||
state.model_selections.set(&req.session_id, provider, model);
|
||||
@ -1270,9 +1274,8 @@ pub async fn topic_select_model(
|
||||
|
||||
// 校验:provider/model 名必须在 config 的 providers/models 表中存在
|
||||
let config = state.config.read().await;
|
||||
if let Some(name) = provider.as_ref()
|
||||
&& !config.providers.contains_key(name)
|
||||
{
|
||||
if let Some(name) = provider.as_ref() {
|
||||
if !config.providers.contains_key(name) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(SelectModelResponse {
|
||||
@ -1281,9 +1284,9 @@ pub async fn topic_select_model(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(name) = model.as_ref()
|
||||
&& !config.models.contains_key(name)
|
||||
{
|
||||
}
|
||||
if let Some(name) = model.as_ref() {
|
||||
if !config.models.contains_key(name) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(SelectModelResponse {
|
||||
@ -1292,6 +1295,7 @@ pub async fn topic_select_model(
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(config);
|
||||
|
||||
let is_clear = provider.is_none() && model.is_none();
|
||||
@ -1316,9 +1320,7 @@ pub async fn topic_select_model(
|
||||
if is_clear {
|
||||
state.model_selections.set(&topic_session_id, None, None);
|
||||
} else {
|
||||
state
|
||||
.model_selections
|
||||
.set(&topic_session_id, provider, model);
|
||||
state.model_selections.set(&topic_session_id, provider, model);
|
||||
}
|
||||
|
||||
(
|
||||
|
||||
@ -707,15 +707,15 @@ pub(crate) fn validate_memory_maintenance_output(
|
||||
}
|
||||
|
||||
// 检查目标 namespace 是否与源一致
|
||||
if let Some(src_ns) = source_namespaces.iter().next()
|
||||
&& *src_ns != merge.namespace
|
||||
{
|
||||
if let Some(src_ns) = source_namespaces.iter().next() {
|
||||
if *src_ns != merge.namespace {
|
||||
return Err(format!(
|
||||
"跨 namespace 合并被禁止: {} → {}",
|
||||
src_ns, merge.namespace
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 3: 总体合并比例
|
||||
let merged_ids: HashSet<&str> = output
|
||||
@ -768,7 +768,7 @@ pub(crate) fn apply_memory_maintenance_output(
|
||||
min_memories_to_keep,
|
||||
max_merge_per_group,
|
||||
)
|
||||
.map_err(AgentError::Other)?;
|
||||
.map_err(|e| AgentError::Other(e))?;
|
||||
|
||||
let all_candidates = plan.candidates.clone();
|
||||
|
||||
@ -834,9 +834,8 @@ pub(crate) fn apply_memory_maintenance_output(
|
||||
}
|
||||
|
||||
for memory_id in &output.low_value_ids {
|
||||
if let Some(candidate) = candidates_by_id.get(memory_id.as_str())
|
||||
&& deleted_ids.insert(candidate.id.clone())
|
||||
{
|
||||
if let Some(candidate) = candidates_by_id.get(memory_id.as_str()) {
|
||||
if deleted_ids.insert(candidate.id.clone()) {
|
||||
store
|
||||
.delete_memory("user", scope_key, &candidate.namespace, &candidate.key)
|
||||
.map_err(|err| {
|
||||
@ -844,6 +843,7 @@ pub(crate) fn apply_memory_maintenance_output(
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:记录整理完成时间
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
@ -31,8 +31,6 @@ pub mod tool_registry_factory;
|
||||
pub mod wait_coordinator;
|
||||
pub mod ws;
|
||||
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::http::{HeaderName, HeaderValue, header};
|
||||
use axum::{Router, middleware, routing};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@ -112,15 +110,8 @@ impl GatewayState {
|
||||
mcp_servers: config.mcp_servers.clone(),
|
||||
};
|
||||
|
||||
let (
|
||||
session_manager,
|
||||
task_repository,
|
||||
mcp_manager,
|
||||
subagent_runtime,
|
||||
model_selections,
|
||||
topic_model_selections,
|
||||
subagent_executor,
|
||||
) = build_session_manager_with_sender(
|
||||
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections, topic_model_selections, subagent_executor) =
|
||||
build_session_manager_with_sender(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
config.time.timezone.clone(),
|
||||
@ -213,57 +204,6 @@ impl GatewayState {
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP 请求体大小上限(显式声明,与 axum 默认一致,防止隐式依赖默认值)。
|
||||
const HTTP_BODY_LIMIT: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// 内容安全策略:
|
||||
/// - script/object/base/frame 全部限制同源或禁用,缓解 XSS 与点击劫持
|
||||
/// - style-src 'unsafe-inline':React 行内 style 属性需要
|
||||
/// - img-src 含 data:/blob::聊天附件以 base64 data URL 渲染、下载走 blob URL
|
||||
/// - connect-src 放开 ws:/wss:/http:/https::前端支持用户自定义网关地址(跨源连接属产品特性)
|
||||
const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
|
||||
script-src 'self'; \
|
||||
style-src 'self' 'unsafe-inline'; \
|
||||
img-src 'self' data: blob:; \
|
||||
font-src 'self' data:; \
|
||||
connect-src 'self' ws: wss: http: https:; \
|
||||
object-src 'none'; \
|
||||
base-uri 'self'; \
|
||||
frame-ancestors 'none'";
|
||||
|
||||
fn insert_header_if_absent(
|
||||
headers: &mut axum::http::HeaderMap,
|
||||
name: HeaderName,
|
||||
value: &'static str,
|
||||
) {
|
||||
headers
|
||||
.entry(name)
|
||||
.or_insert(HeaderValue::from_static(value));
|
||||
}
|
||||
|
||||
/// 安全响应头中间件:为所有响应补充防御性 HTTP 头(已存在的头不覆盖)。
|
||||
async fn security_headers(
|
||||
request: axum::extract::Request,
|
||||
next: middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
insert_header_if_absent(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff");
|
||||
insert_header_if_absent(headers, header::X_FRAME_OPTIONS, "DENY");
|
||||
insert_header_if_absent(headers, header::REFERRER_POLICY, "no-referrer");
|
||||
insert_header_if_absent(
|
||||
headers,
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
CONTENT_SECURITY_POLICY,
|
||||
);
|
||||
insert_header_if_absent(
|
||||
headers,
|
||||
HeaderName::from_static("permissions-policy"),
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
@ -286,11 +226,7 @@ pub async fn run(
|
||||
// 将 pending_subagents 表中所有 status='running' 的记录标记为 'interrupted'。
|
||||
// 下次 wait_for_subagents 调用时,这些 task_id 不会出现在 pending 列表中,
|
||||
// agent 可据此判断子代理未正常完成。
|
||||
match state
|
||||
.session_manager
|
||||
.store()
|
||||
.mark_all_running_as_interrupted()
|
||||
{
|
||||
match state.session_manager.store().mark_all_running_as_interrupted() {
|
||||
Ok(0) => {
|
||||
tracing::info!("Crash recovery: no interrupted subagents to recover");
|
||||
}
|
||||
@ -316,7 +252,7 @@ pub async fn run(
|
||||
// Initialize and start channels
|
||||
state
|
||||
.channel_manager
|
||||
.init(&cfg, provider_config.clone())
|
||||
.init(&*cfg, provider_config.clone())
|
||||
.await?;
|
||||
drop(cfg);
|
||||
state.channel_manager.start_all().await?;
|
||||
@ -346,16 +282,11 @@ pub async fn run(
|
||||
}
|
||||
|
||||
// CLI args override config file values
|
||||
let (bind_host, bind_port, auth_token, allowed_origins) = {
|
||||
let (bind_host, bind_port, auth_token) = {
|
||||
let cfg = state.config.read().await;
|
||||
let h = host.unwrap_or_else(|| cfg.gateway.host.clone());
|
||||
let p = port.unwrap_or(cfg.gateway.port);
|
||||
(
|
||||
h,
|
||||
p,
|
||||
cfg.gateway.auth_token.clone(),
|
||||
cfg.gateway.allowed_origins.clone(),
|
||||
)
|
||||
(h, p, cfg.gateway.auth_token.clone())
|
||||
};
|
||||
|
||||
// 安全校验:绑定到非 loopback 地址时必须配置 auth_token
|
||||
@ -469,62 +400,22 @@ pub async fn run(
|
||||
app.layer(axum::Extension(auth_config))
|
||||
.layer(middleware::from_fn(auth::require_bearer_auth))
|
||||
} else {
|
||||
// loopback 免认证模式:强制 Host 头为 loopback,阻断 DNS rebinding
|
||||
// (重绑定后浏览器发送的 Host 为攻击者域名,会被直接拒绝)。
|
||||
app.layer(middleware::from_fn(auth::require_loopback_host))
|
||||
app
|
||||
};
|
||||
|
||||
// CORS:loopback 下仅允许 loopback 来源(防恶意网页跨域读取本地网关);
|
||||
// 非 loopback 下优先使用 allowed_origins 白名单。
|
||||
// CORS:loopback 下宽松(仅同源);非 loopback 下允许任意来源(由 auth_token 保护)。
|
||||
// 不论哪种情况都显式设置以避免浏览器默认行为差异。
|
||||
let cors = if auth::is_loopback_host(&bind_host) {
|
||||
// 本地:放行 localhost/127.0.0.1 任意端口(覆盖同源与 vite dev 等场景),
|
||||
// 拒绝公网 origin——mirror_request 会回显任意 Origin,反而允许跨域读取,不可用。
|
||||
// 本地开发:同源即可,阻止跨域(防 DNS rebinding)
|
||||
CorsLayer::new()
|
||||
.allow_origin(tower_http::cors::AllowOrigin::predicate(
|
||||
|origin: &HeaderValue, _parts: &axum::http::request::Parts| {
|
||||
origin
|
||||
.to_str()
|
||||
.map(auth::origin_is_loopback)
|
||||
.unwrap_or(false)
|
||||
},
|
||||
))
|
||||
.allow_origin(tower_http::cors::AllowOrigin::mirror_request())
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
} else {
|
||||
// 远程访问:优先 origin 白名单;未配置时允许任意来源(由 token 保护)
|
||||
let origins: Vec<HeaderValue> = allowed_origins
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|o| match o.parse::<HeaderValue>() {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => {
|
||||
tracing::warn!(origin = %o, "gateway.allowed_origins: 非法 origin 已忽略");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if origins.is_empty() {
|
||||
tracing::warn!(
|
||||
"未配置 gateway.allowed_origins:远程部署将允许任意来源跨域访问(由 auth_token 保护)。\
|
||||
建议在 config.json 的 gateway 节显式配置允许的来源白名单。"
|
||||
);
|
||||
// 远程访问:允许跨域,但由 token 保护
|
||||
CorsLayer::permissive()
|
||||
} else {
|
||||
tracing::info!(
|
||||
origins = origins.len(),
|
||||
"CORS origin whitelist enabled for remote access"
|
||||
);
|
||||
CorsLayer::new()
|
||||
.allow_origin(origins)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
}
|
||||
};
|
||||
// 层序:后加的在外层。安全响应头最外层,覆盖所有响应(含 401/CORS 预检)。
|
||||
let app = app
|
||||
.layer(cors)
|
||||
.layer(DefaultBodyLimit::max(HTTP_BODY_LIMIT))
|
||||
.layer(middleware::from_fn(security_headers));
|
||||
let app = app.layer(cors);
|
||||
|
||||
let addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?;
|
||||
let listener = {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
/// per-session 的用户模型覆盖选择存储。
|
||||
///
|
||||
@ -17,7 +17,9 @@ impl ModelSelectionStore {
|
||||
|
||||
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
|
||||
pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
|
||||
let mut selections = self.selections.write();
|
||||
let mut selections = self
|
||||
.selections
|
||||
.write();
|
||||
if provider.is_none() && model.is_none() {
|
||||
selections.remove(session_id);
|
||||
} else {
|
||||
@ -27,7 +29,10 @@ impl ModelSelectionStore {
|
||||
|
||||
/// 读取 session 的用户模型覆盖。
|
||||
pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> {
|
||||
self.selections.read().get(session_id).cloned()
|
||||
self.selections
|
||||
.read()
|
||||
.get(session_id)
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -77,18 +77,30 @@ impl OutboundDispatcher {
|
||||
/// sender task 生命周期与 dispatcher 一致:dispatcher `run()` 退出时
|
||||
/// 通过 cancel token 终止所有 sender task。
|
||||
pub async fn register_channel(&self, name: &str, channel: Arc<dyn Channel + Send + Sync>) {
|
||||
let (high_tx, high_rx) = mpsc::channel::<OutboundMessage>(HIGH_PRIORITY_QUEUE_CAPACITY);
|
||||
let (low_tx, low_rx) = mpsc::channel::<OutboundMessage>(LOW_PRIORITY_QUEUE_CAPACITY);
|
||||
let (high_tx, high_rx) =
|
||||
mpsc::channel::<OutboundMessage>(HIGH_PRIORITY_QUEUE_CAPACITY);
|
||||
let (low_tx, low_rx) =
|
||||
mpsc::channel::<OutboundMessage>(LOW_PRIORITY_QUEUE_CAPACITY);
|
||||
let cancel = CancellationToken::new();
|
||||
|
||||
let channel_name = name.to_string();
|
||||
let cancel_for_task = cancel.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
Self::run_sender_task(&channel_name, channel, high_rx, low_rx, cancel_for_task).await;
|
||||
Self::run_sender_task(
|
||||
&channel_name,
|
||||
channel,
|
||||
high_rx,
|
||||
low_rx,
|
||||
cancel_for_task,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
self.channels.write().await.insert(
|
||||
self.channels
|
||||
.write()
|
||||
.await
|
||||
.insert(
|
||||
name.to_string(),
|
||||
ChannelSink {
|
||||
high_tx,
|
||||
@ -154,7 +166,11 @@ impl OutboundDispatcher {
|
||||
}
|
||||
|
||||
/// 发送单条消息,处理重试结果日志。
|
||||
async fn send_one(channel: &dyn Channel, channel_name: &str, msg: OutboundMessage) {
|
||||
async fn send_one(
|
||||
channel: &dyn Channel,
|
||||
channel_name: &str,
|
||||
msg: OutboundMessage,
|
||||
) {
|
||||
let msg_chat_id = msg.chat_id.clone();
|
||||
let msg_trace_id = msg.trace_id.clone();
|
||||
match Self::send_with_retry(channel, msg).await {
|
||||
@ -403,7 +419,7 @@ mod tests {
|
||||
return Err(ChannelError::ChannelFull);
|
||||
}
|
||||
|
||||
if count < self.fail_first_n {
|
||||
if (count as u32) < self.fail_first_n {
|
||||
return Err(ChannelError::SendError("simulated failure".to_string()));
|
||||
}
|
||||
|
||||
@ -463,42 +479,19 @@ mod tests {
|
||||
let error = make_error_message("c", "chat", "agent failed");
|
||||
let tool_call = make_low_message("c", "chat", "calling tool");
|
||||
let tool_result = OutboundMessage::tool_result(
|
||||
"c",
|
||||
"chat",
|
||||
None,
|
||||
"id",
|
||||
"tool",
|
||||
"result",
|
||||
None,
|
||||
"c", "chat", None, "id", "tool", "result", None,
|
||||
std::collections::HashMap::new(),
|
||||
);
|
||||
let exec_done = OutboundMessage::execution_completed(
|
||||
"c",
|
||||
"chat",
|
||||
None,
|
||||
"c", "chat", None,
|
||||
std::collections::HashMap::new(),
|
||||
);
|
||||
|
||||
assert!(
|
||||
is_high_priority(&assistant),
|
||||
"AssistantResponse should be high priority"
|
||||
);
|
||||
assert!(
|
||||
is_high_priority(&error),
|
||||
"ErrorNotification should be high priority"
|
||||
);
|
||||
assert!(
|
||||
!is_high_priority(&tool_call),
|
||||
"ToolCall should be low priority"
|
||||
);
|
||||
assert!(
|
||||
!is_high_priority(&tool_result),
|
||||
"ToolResult should be low priority"
|
||||
);
|
||||
assert!(
|
||||
!is_high_priority(&exec_done),
|
||||
"ExecutionCompleted should be low priority"
|
||||
);
|
||||
assert!(is_high_priority(&assistant), "AssistantResponse should be high priority");
|
||||
assert!(is_high_priority(&error), "ErrorNotification should be high priority");
|
||||
assert!(!is_high_priority(&tool_call), "ToolCall should be low priority");
|
||||
assert!(!is_high_priority(&tool_result), "ToolResult should be low priority");
|
||||
assert!(!is_high_priority(&exec_done), "ExecutionCompleted should be low priority");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -521,12 +514,8 @@ mod tests {
|
||||
});
|
||||
|
||||
// 先发一条 slow(500ms 延迟),紧接着发一条 fast
|
||||
bus.publish_outbound(make_message("slow", "chat-1", "slow-msg"))
|
||||
.await
|
||||
.unwrap();
|
||||
bus.publish_outbound(make_message("fast", "chat-2", "fast-msg"))
|
||||
.await
|
||||
.unwrap();
|
||||
bus.publish_outbound(make_message("slow", "chat-1", "slow-msg")).await.unwrap();
|
||||
bus.publish_outbound(make_message("fast", "chat-2", "fast-msg")).await.unwrap();
|
||||
|
||||
// 等待 fast 消息被投递(远早于 slow 完成)
|
||||
tokio::time::timeout(Duration::from_millis(200), async {
|
||||
@ -535,9 +524,7 @@ mod tests {
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect(
|
||||
"fast channel should receive message within 200ms, but was blocked by slow channel",
|
||||
);
|
||||
.expect("fast channel should receive message within 200ms, but was blocked by slow channel");
|
||||
|
||||
// 等待 slow 消息完成
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
@ -581,12 +568,8 @@ mod tests {
|
||||
});
|
||||
|
||||
// 先发 flaky(会重试 3 秒),紧接着发 stable
|
||||
bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg"))
|
||||
.await
|
||||
.unwrap();
|
||||
bus.publish_outbound(make_message("stable", "chat-2", "stable-msg"))
|
||||
.await
|
||||
.unwrap();
|
||||
bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg")).await.unwrap();
|
||||
bus.publish_outbound(make_message("stable", "chat-2", "stable-msg")).await.unwrap();
|
||||
|
||||
// stable 应在 200ms 内收到,远早于 flaky 的 3 秒重试完成
|
||||
tokio::time::timeout(Duration::from_millis(200), async {
|
||||
@ -837,10 +820,7 @@ mod tests {
|
||||
.await
|
||||
.expect("high priority should succeed within extended retry budget");
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"high priority should succeed after 4 attempts"
|
||||
);
|
||||
assert!(result.is_ok(), "high priority should succeed after 4 attempts");
|
||||
assert_eq!(
|
||||
call_count.load(Ordering::SeqCst),
|
||||
4,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use futures_util::FutureExt;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use futures_util::FutureExt;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
@ -28,8 +28,8 @@ use crate::providers::{ProviderRuntimeConfig, create_provider};
|
||||
use crate::storage::persistent_session_id;
|
||||
use crate::topic_description::generate_topic_description;
|
||||
|
||||
use super::message_prepare::enrich_user_content_with_media_refs;
|
||||
use super::session::{BusToolCallEmitter, SessionManager};
|
||||
use super::message_prepare::enrich_user_content_with_media_refs;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InboundProcessor {
|
||||
@ -180,7 +180,8 @@ impl InboundProcessor {
|
||||
let chat_id_for_span = inbound.chat_id.clone();
|
||||
let session_id_for_span =
|
||||
crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id);
|
||||
tokio::spawn(crate::observability::tracing_ctx::traced(
|
||||
tokio::spawn(
|
||||
crate::observability::tracing_ctx::traced(
|
||||
&trace_id,
|
||||
&chat_id_for_span,
|
||||
&session_id_for_span,
|
||||
@ -210,7 +211,8 @@ impl InboundProcessor {
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -278,8 +280,8 @@ impl InboundProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(error) = response.error
|
||||
&& let Err(e) = self
|
||||
} else if let Some(error) = response.error {
|
||||
if let Err(e) = self
|
||||
.bus
|
||||
.publish_outbound(
|
||||
OutboundMessage::assistant(
|
||||
@ -303,6 +305,7 @@ impl InboundProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@ -323,9 +326,8 @@ impl InboundProcessor {
|
||||
//
|
||||
// 安全性:is_waiting 在持锁状态下检查,wait_coordinator 清除 is_waiting 需先重获取锁,
|
||||
// 两者互斥,无 TOCTOU。
|
||||
if let Some(ref topic_id) = current_topic
|
||||
&& let Some(session) = self.session_manager.get(&inbound.channel).await
|
||||
{
|
||||
if let Some(ref topic_id) = current_topic {
|
||||
if let Some(session) = self.session_manager.get(&inbound.channel).await {
|
||||
let lock_key = topic_id.clone();
|
||||
|
||||
// 获取 serial_lock Arc(短暂持有 session 锁)
|
||||
@ -360,12 +362,20 @@ impl InboundProcessor {
|
||||
g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?;
|
||||
|
||||
// 构造用户消息(与 prepare_and_execute_message 一致的处理流程)
|
||||
let media_refs: Vec<String> =
|
||||
inbound.media.iter().map(|m| m.path.clone()).collect();
|
||||
let media_refs: Vec<String> = inbound
|
||||
.media
|
||||
.iter()
|
||||
.map(|m| m.path.clone())
|
||||
.collect();
|
||||
let enriched_content =
|
||||
enrich_user_content_with_media_refs(&inbound.content, &media_refs)?;
|
||||
let user_message = g.create_user_message(&enriched_content, media_refs);
|
||||
g.append_persisted_message(&inbound.chat_id, Some(&lock_key), user_message)?;
|
||||
let user_message =
|
||||
g.create_user_message(&enriched_content, media_refs);
|
||||
g.append_persisted_message(
|
||||
&inbound.chat_id,
|
||||
Some(&lock_key),
|
||||
user_message,
|
||||
)?;
|
||||
|
||||
// 获取 wakeup 信号
|
||||
g.wait_wakeup(&lock_key)
|
||||
@ -383,6 +393,7 @@ impl InboundProcessor {
|
||||
}
|
||||
// is_waiting=false:_inject_guard drop 释放锁,走正常 handle_message 路径
|
||||
}
|
||||
}
|
||||
|
||||
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new(
|
||||
BusToolCallEmitter::new(
|
||||
@ -450,27 +461,18 @@ impl InboundProcessor {
|
||||
// 异步生成 topic 描述(仅当描述为空且没有正在进行的生成任务时触发)
|
||||
if let Some(ref topic_id) = current_topic {
|
||||
let store = self.session_manager.store();
|
||||
// SQLite 是同步 I/O:放到 blocking 线程池,避免阻塞 tokio worker
|
||||
let store_for_lookup = store.clone();
|
||||
let topic_id_for_lookup = topic_id.clone();
|
||||
let topic_row = tokio::task::spawn_blocking(move || {
|
||||
store_for_lookup.get_topic(&topic_id_for_lookup)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
.unwrap_or(None);
|
||||
if let Some(topic) = topic_row
|
||||
&& (topic.description.is_none()
|
||||
if let Ok(Some(topic)) = store.get_topic(topic_id) {
|
||||
if topic.description.is_none()
|
||||
|| topic
|
||||
.description
|
||||
.as_ref()
|
||||
.map(|d| d.is_empty())
|
||||
.unwrap_or(true))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
|
||||
let should_generate = {
|
||||
let mut in_flight = self.description_generation_in_flight.lock();
|
||||
let mut in_flight =
|
||||
self.description_generation_in_flight.lock();
|
||||
if in_flight.contains(topic_id) {
|
||||
false
|
||||
} else {
|
||||
@ -486,17 +488,14 @@ impl InboundProcessor {
|
||||
let in_flight = self.description_generation_in_flight.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// 定向查询该 topic 的第一条用户消息(DB 侧 LIMIT 1,
|
||||
// 不再全量加载整个话题历史),并放到 blocking 线程池执行
|
||||
let store_for_query = store_clone.clone();
|
||||
let topic_id_for_query = topic_id_clone.clone();
|
||||
let first_user_message = tokio::task::spawn_blocking(move || {
|
||||
store_for_query.first_user_message_content(&topic_id_for_query)
|
||||
})
|
||||
.await
|
||||
// 从 DB 查询该 topic 的第一条用户消息作为描述生成的依据
|
||||
let first_user_message = store_clone
|
||||
.load_messages_for_topic_full(&topic_id_clone, None)
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
.unwrap_or(None);
|
||||
.and_then(|msgs| {
|
||||
msgs.into_iter().find(|m| m.role == "user")
|
||||
})
|
||||
.map(|m| m.content);
|
||||
|
||||
let message_content = match first_user_message {
|
||||
Some(content) => content,
|
||||
@ -507,7 +506,8 @@ impl InboundProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_config: ProviderRuntimeConfig = provider_config.into();
|
||||
let runtime_config: ProviderRuntimeConfig =
|
||||
provider_config.into();
|
||||
if let Ok(provider) = create_provider(runtime_config) {
|
||||
match generate_topic_description(
|
||||
provider.as_ref(),
|
||||
@ -516,27 +516,15 @@ impl InboundProcessor {
|
||||
.await
|
||||
{
|
||||
Ok(description) => {
|
||||
let store_for_update = store_clone.clone();
|
||||
let topic_id_for_update = topic_id_clone.clone();
|
||||
let description_for_update = description.clone();
|
||||
let update_result =
|
||||
tokio::task::spawn_blocking(move || {
|
||||
store_for_update.update_topic_description(
|
||||
&topic_id_for_update,
|
||||
&description_for_update,
|
||||
if let Err(e) = store_clone
|
||||
.update_topic_description(
|
||||
&topic_id_clone,
|
||||
&description,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
match update_result {
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
{
|
||||
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, topic_id = %topic_id_clone, "Topic description update task panicked");
|
||||
}
|
||||
} else {
|
||||
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@ -551,6 +539,7 @@ impl InboundProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
error = %crate::utils::format_error_chain(&error),
|
||||
@ -599,15 +588,10 @@ impl InboundProcessor {
|
||||
// 恢复路径:下一条用户消息触发新的 process_one → 加载 history →
|
||||
// LLM 看到 "running" 占位 → 调用 wait_for_subagents → 消费 sub_done_q 结果。
|
||||
let has_pending_subagents = if let Some(ref topic_id) = current_topic {
|
||||
// SQLite 是同步 I/O:放到 blocking 线程池,避免阻塞 tokio worker
|
||||
let store = self.session_manager.store();
|
||||
let topic_id_for_query = topic_id.clone();
|
||||
let pending = tokio::task::spawn_blocking(move || {
|
||||
store.list_pending_subagents(&topic_id_for_query, Some("running"))
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.ok())
|
||||
let pending = self
|
||||
.session_manager
|
||||
.store()
|
||||
.list_pending_subagents(topic_id, Some("running"))
|
||||
.unwrap_or_default();
|
||||
if !pending.is_empty() {
|
||||
tracing::debug!(
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
use crate::agent::{
|
||||
AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler,
|
||||
};
|
||||
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler};
|
||||
#[cfg(test)]
|
||||
use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT;
|
||||
use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
|
||||
@ -14,10 +12,10 @@ use crate::storage::{
|
||||
SkillEventRepository,
|
||||
};
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::tools::WaitCoordinator;
|
||||
use crate::tools::task::SubagentResult;
|
||||
use crate::tools::task::repository::TaskRepository;
|
||||
use crate::tools::task::runtime::SubagentRuntime;
|
||||
use crate::tools::task::SubagentResult;
|
||||
use crate::tools::WaitCoordinator;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@ -140,29 +138,9 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// 拦截 todo_write 结果:即时持久化到 SQLite。
|
||||
// SQLite 是同步 I/O:放到 blocking 线程池,避免阻塞 tokio worker;
|
||||
// await 保持与先前同步实现一致的顺序语义(同一 emitter 的连续
|
||||
// todo_write 不会乱序覆盖)。
|
||||
// 拦截 todo_write 结果:即时持久化到 SQLite
|
||||
if message.tool_name.as_deref() == Some("todo_write") {
|
||||
let store = self.store.clone();
|
||||
let channel_name = self.channel_name.clone();
|
||||
let chat_id = self.chat_id.clone();
|
||||
let metadata = self.metadata.clone();
|
||||
let message_clone = message.clone();
|
||||
if let Err(e) = tokio::task::spawn_blocking(move || {
|
||||
Self::persist_todo_write_result_sync(
|
||||
&store,
|
||||
&channel_name,
|
||||
&chat_id,
|
||||
&metadata,
|
||||
&message_clone,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "todo_write persistence task failed");
|
||||
}
|
||||
self.persist_todo_write_result(&message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,14 +193,8 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
}
|
||||
|
||||
impl BusToolCallEmitter {
|
||||
/// 从 todo_write 工具结果中提取 todos 并持久化(同步实现,供 spawn_blocking 调用)
|
||||
fn persist_todo_write_result_sync(
|
||||
store: &Arc<SessionStore>,
|
||||
channel_name: &str,
|
||||
chat_id: &str,
|
||||
metadata: &HashMap<String, String>,
|
||||
message: &ChatMessage,
|
||||
) {
|
||||
/// 从 todo_write 工具结果中提取 todos 并持久化
|
||||
fn persist_todo_write_result(&self, message: &ChatMessage) {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(&message.content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
@ -232,15 +204,20 @@ impl BusToolCallEmitter {
|
||||
return;
|
||||
};
|
||||
|
||||
let session_id = crate::storage::persistent_session_id(channel_name, chat_id);
|
||||
let session_id = crate::storage::persistent_session_id(&self.channel_name, &self.chat_id);
|
||||
// 优先用 topic_id(与 list_todos handler 和 tool 内存状态保持一致)
|
||||
let scope_key = metadata
|
||||
let scope_key = self
|
||||
.metadata
|
||||
.get("topic_id")
|
||||
.filter(|t| !t.is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| session_id.clone());
|
||||
|
||||
let topic_id = metadata.get("topic_id").filter(|t| !t.is_empty()).cloned();
|
||||
let topic_id = self
|
||||
.metadata
|
||||
.get("topic_id")
|
||||
.filter(|t| !t.is_empty())
|
||||
.cloned();
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@ -248,7 +225,7 @@ impl BusToolCallEmitter {
|
||||
.as_secs() as i64;
|
||||
|
||||
// 读取现有 DB 记录,独立对比决定 created_by_message_id 是否更新
|
||||
let existing = store.list_todos(&scope_key).unwrap_or_default();
|
||||
let existing = self.store.list_todos(&scope_key).unwrap_or_default();
|
||||
let existing_map: std::collections::HashMap<&str, &crate::storage::TodoRecord> =
|
||||
existing.iter().map(|r| (r.id.as_str(), r)).collect();
|
||||
|
||||
@ -295,7 +272,7 @@ impl BusToolCallEmitter {
|
||||
"BusToolCallEmitter: persisting todo_write result"
|
||||
);
|
||||
|
||||
if let Err(e) = store.replace_todos(&scope_key, &records) {
|
||||
if let Err(e) = self.store.replace_todos(&scope_key, &records) {
|
||||
tracing::warn!(error = %e, %scope_key, "Failed to persist todo list from BusToolCallEmitter");
|
||||
}
|
||||
}
|
||||
@ -580,11 +557,11 @@ impl Session {
|
||||
}
|
||||
|
||||
// 更新 topic 的最后活跃时间
|
||||
if let Some(ref topic_id) = topic_id
|
||||
&& let Err(e) = self.store.touch_topic(topic_id)
|
||||
{
|
||||
if let Some(ref topic_id) = topic_id {
|
||||
if let Err(e) = self.store.touch_topic(topic_id) {
|
||||
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to touch topic");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -1153,16 +1130,9 @@ impl SessionManager {
|
||||
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题
|
||||
if guard.current_topic(chat_id).is_none() {
|
||||
let session_id = guard.persistent_session_id(chat_id);
|
||||
// SQLite 是同步 I/O:放到 blocking 线程池,避免阻塞 tokio worker。
|
||||
// session 互斥锁继续持有(tokio Mutex 允许跨 await),保证恢复/创建
|
||||
// 话题的原子性语义不变。
|
||||
let store_for_query = self.store.clone();
|
||||
let session_id_for_query = session_id.clone();
|
||||
let topics = tokio::task::spawn_blocking(move || {
|
||||
store_for_query.list_topics(&session_id_for_query)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Topic query task failed: {}", e)))?
|
||||
let topics = self
|
||||
.store
|
||||
.list_topics(&session_id)
|
||||
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
|
||||
|
||||
if let Some(latest_topic) = topics.first() {
|
||||
@ -1177,14 +1147,8 @@ impl SessionManager {
|
||||
} else {
|
||||
// 数据库中也没有话题,自动创建默认话题
|
||||
let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M"));
|
||||
let store_for_create = self.store.clone();
|
||||
let session_id_for_create = session_id.clone();
|
||||
let create_result = tokio::task::spawn_blocking(move || {
|
||||
store_for_create.create_topic(&session_id_for_create, &title, None)
|
||||
})
|
||||
.await;
|
||||
match create_result {
|
||||
Ok(Ok(topic)) => {
|
||||
match self.store.create_topic(&session_id, &title, None) {
|
||||
Ok(topic) => {
|
||||
guard.set_current_topic(chat_id, Some(topic.id.clone()));
|
||||
tracing::info!(
|
||||
chat_id = %chat_id,
|
||||
@ -1194,18 +1158,11 @@ impl SessionManager {
|
||||
"Auto-created default topic for new chat"
|
||||
);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
session_id = %session_id,
|
||||
"Failed to auto-create default topic"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
session_id = %session_id,
|
||||
"Topic creation task failed"
|
||||
"Failed to auto-create default topic"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1313,28 +1270,6 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::scheduler::MaintenanceExecutor for SessionManager {
|
||||
async fn cleanup_expired_sessions(&self) -> usize {
|
||||
self.cleanup_expired_sessions().await
|
||||
}
|
||||
|
||||
async fn run_memory_maintenance_for_all_scopes(
|
||||
&self,
|
||||
) -> anyhow::Result<Vec<crate::scheduler::MaintenanceRunSummary>> {
|
||||
match self.run_memory_maintenance_for_all_scopes().await {
|
||||
Ok(Some(result)) => Ok(vec![crate::scheduler::MaintenanceRunSummary {
|
||||
scope_key: result.scope_key,
|
||||
merges: result.output.merges.len(),
|
||||
conflicts: result.output.conflicts.len(),
|
||||
low_value: result.output.low_value_ids.len(),
|
||||
}]),
|
||||
Ok(None) => Ok(vec![]),
|
||||
Err(error) => Err(anyhow::anyhow!(error.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -3098,3 +3033,25 @@ mod tests {
|
||||
assert!(contents.contains(&"习惯先问方案再要代码".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::scheduler::MaintenanceExecutor for SessionManager {
|
||||
async fn cleanup_expired_sessions(&self) -> usize {
|
||||
self.cleanup_expired_sessions().await
|
||||
}
|
||||
|
||||
async fn run_memory_maintenance_for_all_scopes(
|
||||
&self,
|
||||
) -> anyhow::Result<Vec<crate::scheduler::MaintenanceRunSummary>> {
|
||||
match self.run_memory_maintenance_for_all_scopes().await {
|
||||
Ok(Some(result)) => Ok(vec![crate::scheduler::MaintenanceRunSummary {
|
||||
scope_key: result.scope_key,
|
||||
merges: result.output.merges.len(),
|
||||
conflicts: result.output.conflicts.len(),
|
||||
low_value: result.output.low_value_ids.len(),
|
||||
}]),
|
||||
Ok(None) => Ok(vec![]),
|
||||
Err(error) => Err(anyhow::anyhow!(error.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,7 +76,11 @@ impl SessionHistory {
|
||||
}
|
||||
|
||||
// 收集当前活跃 topic 集合
|
||||
let active: HashSet<&str> = self.chat_topic_ids.values().map(|s| s.as_str()).collect();
|
||||
let active: HashSet<&str> = self
|
||||
.chat_topic_ids
|
||||
.values()
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
|
||||
// 找一个非活跃 topic 驱逐
|
||||
let to_evict = self.topic_histories.keys().find(|tid| {
|
||||
@ -100,11 +104,11 @@ impl SessionHistory {
|
||||
// 检查是否有活跃 agent 任务(serial lock 被持有)
|
||||
// try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐
|
||||
// try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐
|
||||
if let Some(lock) = self.topic_serial_locks.get(*tid)
|
||||
&& lock.try_lock().is_err()
|
||||
{
|
||||
if let Some(lock) = self.topic_serial_locks.get(*tid) {
|
||||
if lock.try_lock().is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
|
||||
@ -172,10 +176,7 @@ impl SessionHistory {
|
||||
|
||||
/// 获取该 topic 的 sub_done 队列 sender(用于后台子代理发送结果)。
|
||||
/// 调用前应已通过 `ensure_sub_done_channel` 创建队列。
|
||||
pub(crate) fn sub_done_sender(
|
||||
&mut self,
|
||||
topic_id: &str,
|
||||
) -> Option<mpsc::Sender<SubagentResult>> {
|
||||
pub(crate) fn sub_done_sender(&mut self, topic_id: &str) -> Option<mpsc::Sender<SubagentResult>> {
|
||||
self.ensure_sub_done_channel(topic_id);
|
||||
self.sub_done_senders.get(topic_id).cloned()
|
||||
}
|
||||
@ -333,15 +334,15 @@ impl SessionHistory {
|
||||
chat_id: &str,
|
||||
topic_id: Option<&str>,
|
||||
) -> Result<(), AgentError> {
|
||||
if let Some(tid) = topic_id
|
||||
&& let Some(history) = self.topic_histories.get_mut(tid)
|
||||
{
|
||||
if let Some(tid) = topic_id {
|
||||
if let Some(history) = self.topic_histories.get_mut(tid) {
|
||||
#[cfg(debug_assertions)]
|
||||
let len = history.len();
|
||||
history.clear();
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(topic_id = %tid, previous_len = len, "Topic history cleared");
|
||||
}
|
||||
}
|
||||
|
||||
self.conversations
|
||||
.clear_messages(&self.persistent_session_id(chat_id))
|
||||
|
||||
@ -149,7 +149,7 @@ mod tests {
|
||||
text: Some("hello".to_string()),
|
||||
// 使用临时目录确保跨平台兼容
|
||||
attachments: vec![MediaItem::new(
|
||||
std::env::temp_dir().join("demo.png").display().to_string(),
|
||||
&std::env::temp_dir().join("demo.png").display().to_string(),
|
||||
"image",
|
||||
)],
|
||||
},
|
||||
|
||||
@ -34,15 +34,15 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
|
||||
None => {
|
||||
// 对于 SPA 应用,如果请求的是页面路由(不是静态资源),返回 index.html
|
||||
// 静态资源通常包含 . (如 .js, .css, .png)
|
||||
if !path.contains('.')
|
||||
&& let Some(index) = StaticAssets::get("index.html")
|
||||
{
|
||||
if !path.contains('.') {
|
||||
if let Some(index) = StaticAssets::get("index.html") {
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/html")
|
||||
.body(Body::from(index.data.into_owned()))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
|
||||
@ -11,12 +11,6 @@ use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
||||
/// - 两者独立演化:新增工具只需在此处加常量,不碰代理身份配置
|
||||
pub struct ToolPromptProvider;
|
||||
|
||||
impl Default for ToolPromptProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolPromptProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
|
||||
@ -111,9 +111,8 @@ impl ToolRegistryFactory {
|
||||
if self.is_enabled("memory_manage") {
|
||||
registry.register(MemoryManageTool::new(self.memories.clone()));
|
||||
}
|
||||
if self.is_enabled("todo_write")
|
||||
&& let Some(ref state) = self.todo_state
|
||||
{
|
||||
if self.is_enabled("todo_write") {
|
||||
if let Some(ref state) = self.todo_state {
|
||||
registry.register(TodoWriteTool::new(
|
||||
state.clone(),
|
||||
self.todo_repository.clone(),
|
||||
@ -123,6 +122,7 @@ impl ToolRegistryFactory {
|
||||
self.todo_repository.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.is_enabled("session_send") {
|
||||
registry.register(SessionSendTool::new(self.session_message_sender.clone()));
|
||||
}
|
||||
@ -157,10 +157,8 @@ impl ToolRegistryFactory {
|
||||
}
|
||||
|
||||
// 注册 Task 工具(如果启用且有 subagent_runtime)
|
||||
if self.is_enabled("task")
|
||||
&& self.task_config.enabled
|
||||
&& let Some(runtime) = &self.subagent_runtime
|
||||
{
|
||||
if self.is_enabled("task") && self.task_config.enabled {
|
||||
if let Some(runtime) = &self.subagent_runtime {
|
||||
registry.register(TaskTool::new(runtime.clone(), None));
|
||||
// 注册 wait_for_subagents 工具(仅主 agent,用于等待异步子代理完成)
|
||||
// 默认超时从配置读取,LLM 可通过 timeout_secs 参数覆盖
|
||||
@ -168,6 +166,7 @@ impl ToolRegistryFactory {
|
||||
self.task_config.wait_default_timeout_secs,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
registry
|
||||
}
|
||||
@ -231,9 +230,8 @@ impl ToolRegistryFactory {
|
||||
}
|
||||
|
||||
// Todo 追踪工具
|
||||
if self.is_enabled("todo_write")
|
||||
&& let Some(ref state) = self.todo_state
|
||||
{
|
||||
if self.is_enabled("todo_write") {
|
||||
if let Some(ref state) = self.todo_state {
|
||||
registry.register(TodoWriteTool::new(
|
||||
state.clone(),
|
||||
self.todo_repository.clone(),
|
||||
@ -243,6 +241,7 @@ impl ToolRegistryFactory {
|
||||
self.todo_repository.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 MCP 工具(如果提供)
|
||||
if let Some(mcp_tools) = mcp_tools {
|
||||
|
||||
@ -97,7 +97,11 @@ impl WaitCoordinator for SessionWaitCoordinator {
|
||||
results
|
||||
}
|
||||
|
||||
async fn wait(&self, timeout: Duration, cancel_rx: Option<watch::Receiver<()>>) -> WaitEvent {
|
||||
async fn wait(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
cancel_rx: Option<watch::Receiver<()>>,
|
||||
) -> WaitEvent {
|
||||
// 1. 设置 waiting=true
|
||||
{
|
||||
let mut session = self.session.lock().await;
|
||||
|
||||
@ -34,7 +34,7 @@ use crate::utils::current_timestamp;
|
||||
use axum::extract::Query;
|
||||
use axum::extract::State;
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
@ -46,13 +46,6 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
const WS_CHANNEL_NAME: &str = "websocket";
|
||||
|
||||
/// WebSocket 单条消息大小上限。
|
||||
/// 前端附件上传上限 50MB,base64 编码后约 67MB,此处留余量取 80MiB;
|
||||
/// 显式设定边界(而非依赖 tungstenite 默认 64MB),防止超大消息耗尽内存。
|
||||
const WS_MAX_MESSAGE_SIZE: usize = 80 * 1024 * 1024;
|
||||
/// WebSocket 单帧大小上限(消息可由多帧组成)。
|
||||
const WS_MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// Default media directory for WebSocket uploads
|
||||
fn default_ws_media_dir() -> PathBuf {
|
||||
let home = crate::platform::picobot_home_dir();
|
||||
@ -144,37 +137,22 @@ pub struct WsAuthQuery {
|
||||
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
headers: HeaderMap,
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Query(query): Query<WsAuthQuery>,
|
||||
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
|
||||
) -> Response {
|
||||
// 若启用了认证(auth_cfg 存在且 token 已配置),校验 query param 中的 token
|
||||
let mut token_verified = false;
|
||||
if let Some(axum::Extension(cfg)) = auth_cfg
|
||||
&& let Some(ref expected) = cfg.token
|
||||
{
|
||||
if let Some(axum::Extension(cfg)) = auth_cfg {
|
||||
if let Some(ref expected) = cfg.token {
|
||||
let provided = query.token.as_deref();
|
||||
if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) {
|
||||
tracing::warn!("WebSocket connection rejected: missing or invalid token");
|
||||
return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response();
|
||||
}
|
||||
token_verified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 无 token 认证保护时(loopback 免认证模式),要求 Origin 为 loopback 来源,
|
||||
// 阻断恶意网页直连/DNS rebinding 发起的跨站 WebSocket 劫持(CSWSH)。
|
||||
// token 校验通过的连接无需检查(攻击者无法从跨域页面拿到 token)。
|
||||
if !token_verified && !crate::gateway::auth::ws_origin_loopback(&headers) {
|
||||
tracing::warn!(
|
||||
"WebSocket connection rejected: non-loopback Origin (possible CSWSH/DNS rebinding)"
|
||||
);
|
||||
return (StatusCode::FORBIDDEN, "origin not allowed").into_response();
|
||||
}
|
||||
|
||||
ws.max_message_size(WS_MAX_MESSAGE_SIZE)
|
||||
.max_frame_size(WS_MAX_FRAME_SIZE)
|
||||
.on_upgrade(|socket| async {
|
||||
ws.on_upgrade(|socket| async {
|
||||
handle_socket(socket, state).await;
|
||||
})
|
||||
}
|
||||
@ -472,7 +450,7 @@ async fn handle_inbound(
|
||||
.await
|
||||
.get_provider_config("default")
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let prompt_repository = state.session_manager.store();
|
||||
let prompt_repository = state.session_manager.store().clone();
|
||||
|
||||
// 与 AgentFactory::create 共享同一构建逻辑,确保 /save、/save-session、
|
||||
// /current 保存/展示的系统提示词与 LLM 实际接收的完全一致
|
||||
@ -675,24 +653,26 @@ async fn handle_inbound(
|
||||
}
|
||||
|
||||
// 处理定时任务列表
|
||||
if let Some(jobs_json) = response.metadata.get("scheduler_jobs")
|
||||
&& let Ok(jobs) =
|
||||
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") {
|
||||
if let Ok(jobs) =
|
||||
serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json)
|
||||
{
|
||||
let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理技能列表
|
||||
if let Some(skills_json) = response.metadata.get("skills")
|
||||
&& let Ok(skills) =
|
||||
if let Some(skills_json) = response.metadata.get("skills") {
|
||||
if let Ok(skills) =
|
||||
serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json)
|
||||
{
|
||||
let _ = sender.send(WsOutbound::SkillList { skills }).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 Todo 列表
|
||||
if let Some(todos_json) = response.metadata.get("todos")
|
||||
&& let Ok(todos) =
|
||||
if let Some(todos_json) = response.metadata.get("todos") {
|
||||
if let Ok(todos) =
|
||||
serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
|
||||
{
|
||||
let scope_key = response
|
||||
@ -703,18 +683,20 @@ async fn handle_inbound(
|
||||
tracing::debug!(todo_count = todos.len(), %scope_key, "list_todos command response");
|
||||
let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理记忆列表
|
||||
if let Some(memories_json) = response.metadata.get("memories")
|
||||
&& let Ok(memories) =
|
||||
if let Some(memories_json) = response.metadata.get("memories") {
|
||||
if let Ok(memories) =
|
||||
serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json)
|
||||
{
|
||||
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 记忆 CRUD 后自动刷新列表
|
||||
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true")
|
||||
&& let Ok(records) =
|
||||
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") {
|
||||
if let Ok(records) =
|
||||
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
|
||||
{
|
||||
let memories: Vec<crate::protocol::MemorySummary> = records
|
||||
@ -731,6 +713,7 @@ async fn handle_inbound(
|
||||
.collect();
|
||||
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理加载聊天消息请求
|
||||
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
||||
@ -755,10 +738,11 @@ async fn handle_inbound(
|
||||
}
|
||||
}
|
||||
|
||||
if current_topic_id.is_none()
|
||||
&& let Some(topics_json) = response.metadata.get("topics")
|
||||
{
|
||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||
if current_topic_id.is_none() {
|
||||
if let Some(topics_json) = response.metadata.get("topics") {
|
||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
|
||||
topics_json,
|
||||
) {
|
||||
Ok(topics) => {
|
||||
if let Some(first_topic) = topics.first() {
|
||||
let topic_id = first_topic.topic_id.clone();
|
||||
@ -781,6 +765,7 @@ async fn handle_inbound(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(ref error) = response.error {
|
||||
tracing::warn!(
|
||||
error_code = %error.code,
|
||||
@ -835,12 +820,12 @@ async fn send_topic_history(
|
||||
let mut tool_call_ids_with_results: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
for msg in &messages {
|
||||
if msg.role == "tool"
|
||||
&& let Some(ref tcid) = msg.tool_call_id
|
||||
{
|
||||
if msg.role == "tool" {
|
||||
if let Some(ref tcid) = msg.tool_call_id {
|
||||
tool_call_ids_with_results.insert(tcid.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将消息转换为 WsOutbound 并发送
|
||||
for msg in messages {
|
||||
@ -909,8 +894,7 @@ fn reconcile_running_in_messages(
|
||||
topic_id: &str,
|
||||
) {
|
||||
let has_running_placeholder = messages.iter().any(|m| {
|
||||
m.role == "tool"
|
||||
&& crate::gateway::session::extract_task_id_from_content(&m.content).is_some()
|
||||
m.role == "tool" && crate::gateway::session::extract_task_id_from_content(&m.content).is_some()
|
||||
});
|
||||
if !has_running_placeholder {
|
||||
return; // 无需查询 DB
|
||||
@ -932,15 +916,13 @@ fn reconcile_running_in_messages(
|
||||
if msg.role != "tool" {
|
||||
continue;
|
||||
}
|
||||
let Some((task_id, is_json)) =
|
||||
crate::gateway::session::extract_task_id_from_content(&msg.content)
|
||||
let Some((task_id, is_json)) = crate::gateway::session::extract_task_id_from_content(&msg.content)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match status_map.get(task_id.as_str()) {
|
||||
Some(&status) if status != "running" => {
|
||||
msg.content =
|
||||
crate::gateway::session::format_reconciled_content(&task_id, status, is_json);
|
||||
msg.content = crate::gateway::session::format_reconciled_content(&task_id, status, is_json);
|
||||
}
|
||||
_ => {} // 不存在(已清理)或仍在运行:保留原占位
|
||||
}
|
||||
@ -963,12 +945,12 @@ async fn send_task_messages(
|
||||
let mut tool_call_ids_with_results: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
for msg in &messages {
|
||||
if msg.role == "tool"
|
||||
&& let Some(ref tcid) = msg.tool_call_id
|
||||
{
|
||||
if msg.role == "tool" {
|
||||
if let Some(ref tcid) = msg.tool_call_id {
|
||||
tool_call_ids_with_results.insert(tcid.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for msg in messages {
|
||||
let mut outbounds = chat_message_to_ws_outbound(&msg);
|
||||
@ -1059,11 +1041,11 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) {
|
||||
fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option<String> {
|
||||
let parent = &task.parent_session_id;
|
||||
// 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}")
|
||||
if parent.starts_with("sub:")
|
||||
&& let Some(pos) = parent.find(":task:")
|
||||
{
|
||||
if parent.starts_with("sub:") {
|
||||
if let Some(pos) = parent.find(":task:") {
|
||||
return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ use chrono_tz::Tz;
|
||||
use std::path::PathBuf;
|
||||
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
||||
use tracing_subscriber::{
|
||||
EnvFilter, Layer, fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt,
|
||||
fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
|
||||
};
|
||||
|
||||
use crate::config::LogFormat;
|
||||
@ -61,15 +61,15 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
|
||||
let log_dir = get_default_log_dir();
|
||||
|
||||
// Create log directory if it doesn't exist
|
||||
if !log_dir.exists()
|
||||
&& let Err(e) = std::fs::create_dir_all(&log_dir)
|
||||
{
|
||||
if !log_dir.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
||||
eprintln!(
|
||||
"Warning: Failed to create log directory {}: {}",
|
||||
log_dir.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create file appender with daily rotation
|
||||
let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log");
|
||||
|
||||
@ -6,9 +6,9 @@
|
||||
//! - Connects to MCP servers asynchronously
|
||||
//! - Dynamically registers MCP tools via the Tool trait adapter
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use http::{HeaderName, HeaderValue};
|
||||
|
||||
@ -102,7 +102,7 @@ impl McpServerConfig {
|
||||
command,
|
||||
args: self.args.clone().unwrap_or_default(),
|
||||
env: self.env.clone().unwrap_or_default(),
|
||||
cwd: self.cwd.as_ref().map(std::path::PathBuf::from),
|
||||
cwd: self.cwd.as_ref().map(|s| std::path::PathBuf::from(s)),
|
||||
})
|
||||
}
|
||||
"http" | "streamableHttp" => {
|
||||
|
||||
@ -111,7 +111,10 @@ impl PicoBotTool for McpToolWrapper {
|
||||
.call_tool(&self.server_key, &self.tool_name, args);
|
||||
|
||||
let result = if self.timeout_secs > 0 {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), call)
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(self.timeout_secs),
|
||||
call,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
tracing::warn!(
|
||||
@ -180,8 +183,12 @@ pub async fn register_mcp_tools(
|
||||
let all_tools = manager.all_tools().await;
|
||||
|
||||
for (server_key, tool_info) in all_tools {
|
||||
let wrapper =
|
||||
McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info, timeout_secs);
|
||||
let wrapper = McpToolWrapper::new(
|
||||
manager.clone(),
|
||||
server_key.clone(),
|
||||
tool_info,
|
||||
timeout_secs,
|
||||
);
|
||||
|
||||
tracing::info!(
|
||||
name = %wrapper.name(),
|
||||
|
||||
@ -40,8 +40,7 @@ pub const MESSAGE_PROCESSING_ERRORS: &str = "picobot_message_processing_errors_t
|
||||
/// 幂等:首次调用安装 recorder 并缓存 handle;后续调用(含热重启)返回缓存的 handle。
|
||||
/// 这避免了热重启后 `install_recorder()` 因 recorder 已安装而失败、导致 `/metrics` 返回 503 的问题。
|
||||
/// 返回 None 表示安装失败(非致命,metrics 静默降级)。
|
||||
static PROMETHEUS_HANDLE: std::sync::OnceLock<Option<PrometheusHandle>> =
|
||||
std::sync::OnceLock::new();
|
||||
static PROMETHEUS_HANDLE: std::sync::OnceLock<Option<PrometheusHandle>> = std::sync::OnceLock::new();
|
||||
|
||||
pub fn init_recorder() -> Option<PrometheusHandle> {
|
||||
PROMETHEUS_HANDLE
|
||||
|
||||
@ -342,7 +342,7 @@ pub fn home_dir() -> Option<PathBuf> {
|
||||
// Windows: support USERPROFILE
|
||||
env::var_os("USERPROFILE").map(PathBuf::from)
|
||||
})
|
||||
.or_else(dirs::home_dir)
|
||||
.or_else(|| dirs::home_dir())
|
||||
}
|
||||
|
||||
/// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。
|
||||
|
||||
@ -125,6 +125,7 @@ pub struct AnthropicProvider {
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
extra_headers: HashMap<String, String>,
|
||||
#[cfg_attr(not(debug_assertions), allow(dead_code))]
|
||||
llm_timeout_secs: u64,
|
||||
model_id: String,
|
||||
temperature: Option<f32>,
|
||||
@ -315,15 +316,15 @@ impl LLMProvider for AnthropicProvider {
|
||||
req_builder = req_builder.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let resp = req_builder.json(&body).send().await.inspect_err(|e| {
|
||||
let resp = req_builder.json(&body).send().await.map_err(|e| {
|
||||
tracing::error!(
|
||||
provider = %self.name,
|
||||
model = %self.model_id,
|
||||
url = %url,
|
||||
timeout_secs = self.llm_timeout_secs,
|
||||
error = %format_error_chain(e),
|
||||
error = %format_error_chain(&e),
|
||||
"Anthropic: HTTP request failed"
|
||||
);
|
||||
e
|
||||
})?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
@ -634,7 +635,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_error_chain_single() {
|
||||
let err = std::io::Error::other("single error");
|
||||
let err = std::io::Error::new(std::io::ErrorKind::Other, "single error");
|
||||
let chain = format_error_chain(&err);
|
||||
assert_eq!(chain, "single error");
|
||||
}
|
||||
@ -648,7 +649,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_error_chain_nested() {
|
||||
let inner = std::io::Error::other("root cause");
|
||||
let inner = std::io::Error::new(std::io::ErrorKind::Other, "root cause");
|
||||
let outer = OuterError::Wrapped(inner);
|
||||
let chain = format_error_chain(&outer);
|
||||
assert!(chain.contains("outer wrapper"));
|
||||
|
||||
@ -63,20 +63,23 @@ impl StreamingAccumulator {
|
||||
name: Option<&str>,
|
||||
arguments: Option<&str>,
|
||||
) {
|
||||
let entry = self.tool_calls.entry(index).or_default();
|
||||
let entry = self
|
||||
.tool_calls
|
||||
.entry(index)
|
||||
.or_insert_with(StreamingToolCall::default);
|
||||
|
||||
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
|
||||
if let Some(id) = id
|
||||
&& !id.is_empty()
|
||||
{
|
||||
if let Some(id) = id {
|
||||
if !id.is_empty() {
|
||||
entry.id = id.to_string();
|
||||
}
|
||||
}
|
||||
// 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值
|
||||
if let Some(name) = name
|
||||
&& !name.is_empty()
|
||||
{
|
||||
if let Some(name) = name {
|
||||
if !name.is_empty() {
|
||||
entry.name = name.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(args) = arguments {
|
||||
entry.arguments.push_str(args);
|
||||
}
|
||||
@ -104,8 +107,8 @@ impl StreamingAccumulator {
|
||||
.into_iter()
|
||||
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
|
||||
.map(|(_, call)| {
|
||||
let arguments =
|
||||
serde_json::from_str(&call.arguments).unwrap_or(serde_json::Value::Null);
|
||||
let arguments = serde_json::from_str(&call.arguments)
|
||||
.unwrap_or_else(|_| serde_json::Value::Null);
|
||||
ToolCall {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
@ -215,24 +218,26 @@ fn convert_content_blocks(
|
||||
}
|
||||
|
||||
// 如果只有一个文本块且没有通知,返回字符串形式
|
||||
if converted_blocks.len() == 1
|
||||
&& let Some(block) = converted_blocks.first()
|
||||
&& block.get("type").and_then(|t| t.as_str()) == Some("text")
|
||||
&& let Some(text) = block.get("text").and_then(|t| t.as_str())
|
||||
{
|
||||
if converted_blocks.len() == 1 {
|
||||
if let Some(block) = converted_blocks.first() {
|
||||
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
return Value::String(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Value::Array(converted_blocks);
|
||||
}
|
||||
}
|
||||
|
||||
// 原有逻辑 - 模型支持图片,正常转换
|
||||
if blocks.len() == 1
|
||||
&& let ContentBlock::Text { text } = &blocks[0]
|
||||
{
|
||||
if blocks.len() == 1 {
|
||||
if let ContentBlock::Text { text } = &blocks[0] {
|
||||
return Value::String(text.clone());
|
||||
}
|
||||
}
|
||||
Value::Array(
|
||||
blocks
|
||||
.iter()
|
||||
@ -476,13 +481,15 @@ impl OpenAIProvider {
|
||||
}
|
||||
|
||||
// 提取流式末帧的 usage(stream_options.include_usage=true 时返回)
|
||||
if let Some(usage_val) = json.get("usage")
|
||||
&& !usage_val.is_null()
|
||||
&& let Ok(u) =
|
||||
if let Some(usage_val) = json.get("usage") {
|
||||
if !usage_val.is_null() {
|
||||
if let Ok(u) =
|
||||
serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
||||
{
|
||||
accumulator.set_usage(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 choices
|
||||
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
|
||||
@ -598,12 +605,14 @@ impl OpenAIProvider {
|
||||
}
|
||||
|
||||
// 提取流式末帧的 usage(与主循环一致)
|
||||
if let Some(usage_val) = json.get("usage")
|
||||
&& !usage_val.is_null()
|
||||
&& let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
||||
if let Some(usage_val) = json.get("usage") {
|
||||
if !usage_val.is_null() {
|
||||
if let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
||||
{
|
||||
accumulator.set_usage(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
|
||||
for choice in choices {
|
||||
@ -675,10 +684,8 @@ impl OpenAIProvider {
|
||||
|
||||
// 回退:当流式解析未获取到任何内容且无 tool call 时,
|
||||
// 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体
|
||||
if response.content.is_empty()
|
||||
&& response.tool_calls.is_empty()
|
||||
&& let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body)
|
||||
{
|
||||
if response.content.is_empty() && response.tool_calls.is_empty() {
|
||||
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
|
||||
let fallback_content = openai_resp
|
||||
.choices
|
||||
.first()
|
||||
@ -725,6 +732,7 @@ impl OpenAIProvider {
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
content_len = response.content.len(),
|
||||
@ -753,16 +761,15 @@ impl OpenAIProvider {
|
||||
std::collections::HashSet::new();
|
||||
|
||||
for (i, m) in request.messages.iter().enumerate().rev() {
|
||||
if m.role == "tool"
|
||||
&& let Some(ref tc_id) = m.tool_call_id
|
||||
{
|
||||
if m.role == "tool" {
|
||||
if let Some(ref tc_id) = m.tool_call_id {
|
||||
resolved_tool_ids.insert(tc_id.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
if m.role == "assistant"
|
||||
&& let Some(ref calls) = m.tool_calls
|
||||
&& !calls.is_empty()
|
||||
{
|
||||
if m.role == "assistant" {
|
||||
if let Some(ref calls) = m.tool_calls {
|
||||
if !calls.is_empty() {
|
||||
let all_resolved = calls
|
||||
.iter()
|
||||
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
||||
@ -775,6 +782,8 @@ impl OpenAIProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward-order check: verify tool messages IMMEDIATELY follow the
|
||||
// assistant(tool_calls). If any non-tool message appears between the
|
||||
@ -818,27 +827,25 @@ impl OpenAIProvider {
|
||||
}
|
||||
|
||||
if m.role == "assistant" {
|
||||
if let Some(ref calls) = m.tool_calls
|
||||
&& !calls.is_empty()
|
||||
&& !skip_assistant_indices.contains(&i)
|
||||
{
|
||||
if let Some(ref calls) = m.tool_calls {
|
||||
if !calls.is_empty() && !skip_assistant_indices.contains(&i) {
|
||||
pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect();
|
||||
pending_assistant_idx = Some(i);
|
||||
}
|
||||
} else if m.role == "tool"
|
||||
&& let Some(ref tc_id) = m.tool_call_id
|
||||
{
|
||||
}
|
||||
} else if m.role == "tool" {
|
||||
if let Some(ref tc_id) = m.tool_call_id {
|
||||
pending_tool_ids.remove(tc_id.as_str());
|
||||
if pending_tool_ids.is_empty() {
|
||||
pending_assistant_idx = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle trailing assistant with unresolved immediate tool results
|
||||
if !pending_tool_ids.is_empty()
|
||||
&& let Some(idx) = pending_assistant_idx
|
||||
{
|
||||
if !pending_tool_ids.is_empty() {
|
||||
if let Some(idx) = pending_assistant_idx {
|
||||
skip_assistant_indices.insert(idx);
|
||||
tracing::warn!(
|
||||
message_index = idx,
|
||||
@ -853,6 +860,7 @@ impl OpenAIProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
|
||||
// whose parent assistant has ALL results after it)
|
||||
@ -948,10 +956,11 @@ impl OpenAIProvider {
|
||||
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
|
||||
});
|
||||
|
||||
if m.role == "assistant"
|
||||
&& let Some(reasoning_content) = &m.reasoning_content {
|
||||
if m.role == "assistant" {
|
||||
if let Some(reasoning_content) = &m.reasoning_content {
|
||||
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Some(message)
|
||||
}
|
||||
@ -1141,8 +1150,8 @@ impl LLMProvider for OpenAIProvider {
|
||||
for (i, msg) in msgs.iter().enumerate() {
|
||||
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
|
||||
for (j, item) in content.iter().enumerate() {
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("image_url")
|
||||
&& let Some(url_str) = item
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("image_url") {
|
||||
if let Some(url_str) = item
|
||||
.get("image_url")
|
||||
.and_then(|u| u.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
@ -1155,6 +1164,7 @@ impl LLMProvider for OpenAIProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut req_builder = self
|
||||
.client
|
||||
|
||||
@ -734,14 +734,14 @@ impl RuntimeJob {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(max_runs) = self.max_runs
|
||||
&& self.run_count >= max_runs
|
||||
{
|
||||
if let Some(max_runs) = self.max_runs {
|
||||
if self.run_count >= max_runs {
|
||||
self.state = SchedulerJobState::Completed;
|
||||
self.next_fire_at = None;
|
||||
self.completed_at = Some(now.timestamp_millis());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let reference_ms = self.next_fire_at.or(self.last_fired_at);
|
||||
self.state = SchedulerJobState::Scheduled;
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
use crate::platform::{
|
||||
atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
#[cfg(test)]
|
||||
static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
@ -143,7 +143,9 @@ impl SkillRuntime {
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.catalog.read().is_empty()
|
||||
self.catalog
|
||||
.read()
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
@ -151,7 +153,9 @@ impl SkillRuntime {
|
||||
}
|
||||
|
||||
pub fn system_index_prompt(&self) -> Option<String> {
|
||||
self.catalog.read().system_index_prompt()
|
||||
self.catalog
|
||||
.read()
|
||||
.system_index_prompt()
|
||||
}
|
||||
|
||||
/// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。
|
||||
@ -166,23 +170,34 @@ impl SkillRuntime {
|
||||
}
|
||||
|
||||
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
||||
self.catalog.read().discovery_event_payload()
|
||||
self.catalog
|
||||
.read()
|
||||
.discovery_event_payload()
|
||||
}
|
||||
|
||||
pub fn offered_event_payload(&self) -> serde_json::Value {
|
||||
self.catalog.read().offered_event_payload()
|
||||
self.catalog
|
||||
.read()
|
||||
.offered_event_payload()
|
||||
}
|
||||
|
||||
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
|
||||
self.catalog.read().activation_payload(name)
|
||||
self.catalog
|
||||
.read()
|
||||
.activation_payload(name)
|
||||
}
|
||||
|
||||
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
|
||||
self.catalog.read().activation_event_payload(name)
|
||||
self.catalog
|
||||
.read()
|
||||
.activation_event_payload(name)
|
||||
}
|
||||
|
||||
pub fn list_skills(&self) -> Vec<Skill> {
|
||||
self.catalog.read().skills.clone()
|
||||
self.catalog
|
||||
.read()
|
||||
.skills
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// List all discovered skills including disabled ones, with their disabled scopes.
|
||||
@ -211,7 +226,10 @@ impl SkillRuntime {
|
||||
}
|
||||
|
||||
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
||||
self.catalog.read().find_skill(name).cloned()
|
||||
self.catalog
|
||||
.read()
|
||||
.find_skill(name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn create_skill(
|
||||
@ -432,7 +450,7 @@ impl SkillCatalog {
|
||||
// Load from least specific to most specific so later sources win on conflicts.
|
||||
for source in source_order(&config.sources) {
|
||||
sources_seen += 1;
|
||||
let root = source_root(&source, cwd);
|
||||
let root = source_root(&source, &cwd);
|
||||
|
||||
let Some(root) = root else { continue };
|
||||
for skill in load_skills_from_root(&root, source.clone()) {
|
||||
@ -501,7 +519,7 @@ impl SkillCatalog {
|
||||
.filter(|s| {
|
||||
allowed_set
|
||||
.as_ref()
|
||||
.is_none_or(|set| set.contains(s.name.as_str()))
|
||||
.map_or(true, |set| set.contains(s.name.as_str()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@ -299,7 +299,7 @@ pub(super) fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError>
|
||||
}
|
||||
|
||||
// Column migration: add created_by_message_id if it doesn't exist
|
||||
let has_column = has_column(conn, "todos", "created_by_message_id")?;
|
||||
let has_column = has_column(&conn, "todos", "created_by_message_id")?;
|
||||
if !has_column {
|
||||
tracing::info!("Adding created_by_message_id column to todos table");
|
||||
conn.execute(
|
||||
|
||||
@ -574,7 +574,9 @@ impl SessionStore {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, provider, model FROM topics WHERE provider IS NOT NULL OR model IS NOT NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
@ -1025,7 +1027,7 @@ impl SessionStore {
|
||||
new_messages.iter().partition(|m| {
|
||||
m.system_context
|
||||
.as_deref()
|
||||
.is_some_and(|sc| sc.starts_with("history_compaction"))
|
||||
.map_or(false, |sc| sc.starts_with("history_compaction"))
|
||||
});
|
||||
|
||||
// 先删除该 topic 下已有的旧压缩摘要(system_context LIKE 'history_compaction%')。
|
||||
@ -1785,33 +1787,6 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 定向查询指定话题的第一条 user 消息内容。
|
||||
///
|
||||
/// 数据库侧 `LIMIT 1`,避免为取单条消息全量加载并反序列化整个话题历史
|
||||
/// (话题越长,全量加载的 CPU/内存浪费越大)。
|
||||
pub fn first_user_message_content(
|
||||
&self,
|
||||
topic_id: &str,
|
||||
) -> Result<Option<String>, StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"
|
||||
SELECT content
|
||||
FROM messages
|
||||
WHERE topic_id = ?1 AND role = 'user'
|
||||
AND (system_context IS NULL OR system_context NOT LIKE 'history_compaction%')
|
||||
ORDER BY seq ASC
|
||||
LIMIT 1
|
||||
",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![topic_id], |row| row.get::<_, String>(0))?;
|
||||
match rows.next() {
|
||||
Some(Ok(content)) => Ok(Some(content)),
|
||||
Some(Err(e)) => Err(e.into()),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定话题的消息数量。
|
||||
///
|
||||
/// 使用 `SELECT COUNT(*)` 在数据库侧计数,避免将所有消息
|
||||
|
||||
@ -36,10 +36,6 @@ pub trait ConversationRepository: Send + Sync + 'static {
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Vec<ChatMessage>, StorageError>;
|
||||
|
||||
/// 定向查询指定话题的第一条 user 消息内容(数据库侧 LIMIT 1)。
|
||||
/// 避免为取单条消息而全量加载并反序列化整个话题历史。
|
||||
fn first_user_message_content(&self, topic_id: &str) -> Result<Option<String>, StorageError>;
|
||||
|
||||
fn append_message(&self, session_id: &str, message: &ChatMessage) -> Result<(), StorageError>;
|
||||
|
||||
fn append_message_with_topic(
|
||||
@ -302,10 +298,6 @@ impl ConversationRepository for super::SessionStore {
|
||||
super::SessionStore::load_messages_for_topic_full(self, topic_id, session_id)
|
||||
}
|
||||
|
||||
fn first_user_message_content(&self, topic_id: &str) -> Result<Option<String>, StorageError> {
|
||||
super::SessionStore::first_user_message_content(self, topic_id)
|
||||
}
|
||||
|
||||
fn compact_topic_history(
|
||||
&self,
|
||||
session_id: &str,
|
||||
|
||||
@ -186,9 +186,7 @@ pub struct MemoryUpsert {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum SchedulerJobState {
|
||||
#[default]
|
||||
Scheduled,
|
||||
Running,
|
||||
Paused,
|
||||
@ -243,6 +241,12 @@ impl SchedulerJobStatus {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SchedulerJobState {
|
||||
fn default() -> Self {
|
||||
Self::Scheduled
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SchedulerJobRecord {
|
||||
pub id: String,
|
||||
|
||||
@ -13,16 +13,10 @@ use tokio::time::{Instant, sleep_until};
|
||||
use crate::platform::{ShellInfo, dangerous_command_patterns};
|
||||
use crate::tools::shell_session::ShellSessionManager;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use crate::tools::{check_null_args, extract_u64};
|
||||
use crate::tools::{check_null_args, extract_bool, extract_u64};
|
||||
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_OUTPUT_CHARS: usize = 50_000;
|
||||
/// 运行时单流输出缓冲上限(字节):超出后保留头尾、丢弃中段,
|
||||
/// 防止长输出命令(或交互式会话的 drain 任务)无限增长吃满内存。
|
||||
const MAX_RUNTIME_BUFFER_BYTES: usize = 1024 * 1024;
|
||||
/// pending 短语增量检测的尾部窗口(字节):交互提示只出现在输出尾部,
|
||||
/// 只需"新 chunk + 尾部窗口"即可捕获(窗口 ≥ 最长短语长度,覆盖跨 chunk 边界)。
|
||||
const PENDING_WINDOW_BYTES: usize = 2048;
|
||||
/// 子进程退出后,等待 read_stream 把管道残余输出排空的最长时间。
|
||||
///
|
||||
/// 不能无界等待 EOF:若子进程派生了继承 stdout 管道的守护进程
|
||||
@ -35,79 +29,6 @@ const INTERACTIVE_HINT: &str =
|
||||
const NON_INTERACTIVE_HINT: &str =
|
||||
"该命令正在等待你完成外部操作。完成后请告诉我继续,或重新运行后续检查命令。";
|
||||
|
||||
/// "等待用户操作"检测短语(全部小写;检测前对输出做 to_lowercase)。
|
||||
/// 新增短语时保持小写,并确保长度不超过 PENDING_WINDOW_BYTES。
|
||||
const PENDING_USER_ACTION_PHRASES: &[&str] = &[
|
||||
// 中文 — 原有
|
||||
"等待用户授权",
|
||||
"等待授权",
|
||||
"等待你授权",
|
||||
"在浏览器中打开以下链接进行认证",
|
||||
// 中文 — 新增(lark-cli 等工具的常见提示)
|
||||
"请在浏览器中",
|
||||
"请打开以下链接",
|
||||
"打开以下链接",
|
||||
"打开链接",
|
||||
"访问以下",
|
||||
"访问此链接",
|
||||
"复制链接",
|
||||
"输入验证码",
|
||||
"输入授权码",
|
||||
"完成认证",
|
||||
"完成授权",
|
||||
"请登录",
|
||||
"正在等待",
|
||||
"等待用户",
|
||||
"手动授权",
|
||||
// 英文 — 原有
|
||||
"open the following link",
|
||||
"waiting for authorization",
|
||||
"waiting for user authorization",
|
||||
"waiting for approval",
|
||||
"device/verify",
|
||||
"user_code=",
|
||||
// 英文 — 新增
|
||||
"visit the following url",
|
||||
"visit this url",
|
||||
"open the following url",
|
||||
"browser to authenticate",
|
||||
"browser to complete",
|
||||
"enter the code",
|
||||
"enter code",
|
||||
"verification code",
|
||||
"authorization code",
|
||||
"one-time code",
|
||||
"device code",
|
||||
"oauth",
|
||||
"go to the following",
|
||||
"navigate to the following",
|
||||
"paste the code",
|
||||
];
|
||||
|
||||
/// 在小写化文本中检测 pending 短语(调用方需先 to_lowercase)。
|
||||
fn contains_pending_phrase(lowercase_text: &str) -> bool {
|
||||
PENDING_USER_ACTION_PHRASES
|
||||
.iter()
|
||||
.any(|phrase| lowercase_text.contains(phrase))
|
||||
}
|
||||
|
||||
/// 缓冲超限时保留头尾(头 1/2 + 尾 1/4),在字符边界处截断。
|
||||
/// 头尾之外的中段对最终 truncate_output(头+尾各 25K 字符)已无贡献。
|
||||
pub(crate) fn cap_output_buffer(buf: &mut String) {
|
||||
if buf.len() <= MAX_RUNTIME_BUFFER_BYTES {
|
||||
return;
|
||||
}
|
||||
let head_len = MAX_RUNTIME_BUFFER_BYTES / 2;
|
||||
let tail_len = MAX_RUNTIME_BUFFER_BYTES / 4;
|
||||
let head_end = buf.floor_char_boundary(head_len);
|
||||
let tail_start = buf.floor_char_boundary(buf.len().saturating_sub(tail_len));
|
||||
let mut capped = String::with_capacity(head_end + tail_len + 64);
|
||||
capped.push_str(&buf[..head_end]);
|
||||
capped.push_str("\n[... output trimmed in memory (buffer limit reached) ...]\n");
|
||||
capped.push_str(&buf[tail_start..]);
|
||||
*buf = capped;
|
||||
}
|
||||
|
||||
/// Shell 类型枚举,支持跨平台
|
||||
///
|
||||
/// 这是 ShellInfo 的兼容包装,提供更方便的 API。
|
||||
@ -171,7 +92,7 @@ impl ShellKind {
|
||||
let info = self.to_info();
|
||||
info.args
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|s| *s)
|
||||
.chain(std::iter::once(command))
|
||||
.collect()
|
||||
}
|
||||
@ -200,9 +121,7 @@ impl ShellKind {
|
||||
pub struct BashTool {
|
||||
timeout_secs: u64,
|
||||
working_dir: Option<String>,
|
||||
/// 危险命令拦截正则:构造时预编译(模式串在构造后不变),
|
||||
/// 避免每次执行命令都重新编译全部正则。
|
||||
deny_patterns: Vec<regex::Regex>,
|
||||
deny_patterns: Vec<String>,
|
||||
shell: ShellKind,
|
||||
session_manager: Arc<ShellSessionManager>,
|
||||
}
|
||||
@ -212,16 +131,7 @@ impl BashTool {
|
||||
Self {
|
||||
timeout_secs: 60,
|
||||
working_dir: None,
|
||||
deny_patterns: dangerous_command_patterns()
|
||||
.into_iter()
|
||||
.filter_map(|p| match regex::Regex::new(&p) {
|
||||
Ok(re) => Some(re),
|
||||
Err(e) => {
|
||||
tracing::warn!(pattern = %p, error = %e, "Invalid deny pattern skipped");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
deny_patterns: dangerous_command_patterns(),
|
||||
shell: ShellKind::detect(),
|
||||
session_manager,
|
||||
}
|
||||
@ -244,11 +154,15 @@ impl BashTool {
|
||||
|
||||
fn guard_command(&self, command: &str) -> Option<String> {
|
||||
let lower = command.to_lowercase();
|
||||
for re in &self.deny_patterns {
|
||||
if re.is_match(&lower) {
|
||||
for pattern in &self.deny_patterns {
|
||||
if regex::Regex::new(pattern)
|
||||
.ok()
|
||||
.map(|re| re.is_match(&lower))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(format!(
|
||||
"Command blocked by safety guard (dangerous pattern: {})",
|
||||
re.as_str()
|
||||
pattern
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -294,6 +208,63 @@ impl BashTool {
|
||||
PENDING_USER_ACTION_MARKER, session_line, hint, output_section
|
||||
)
|
||||
}
|
||||
|
||||
fn should_return_pending(&self, _interactive: bool, output: &str) -> bool {
|
||||
let normalized = output.to_lowercase();
|
||||
let has_auth_phrase = [
|
||||
// 中文 — 原有
|
||||
"等待用户授权",
|
||||
"等待授权",
|
||||
"等待你授权",
|
||||
"在浏览器中打开以下链接进行认证",
|
||||
// 中文 — 新增(lark-cli 等工具的常见提示)
|
||||
"请在浏览器中",
|
||||
"请打开以下链接",
|
||||
"打开以下链接",
|
||||
"打开链接",
|
||||
"访问以下",
|
||||
"访问此链接",
|
||||
"复制链接",
|
||||
"输入验证码",
|
||||
"输入授权码",
|
||||
"完成认证",
|
||||
"完成授权",
|
||||
"请登录",
|
||||
"正在等待",
|
||||
"等待用户",
|
||||
"手动授权",
|
||||
// 英文 — 原有
|
||||
"open the following link",
|
||||
"waiting for authorization",
|
||||
"waiting for user authorization",
|
||||
"waiting for approval",
|
||||
"device/verify",
|
||||
"user_code=",
|
||||
// 英文 — 新增
|
||||
"visit the following url",
|
||||
"visit this url",
|
||||
"open the following url",
|
||||
"browser to authenticate",
|
||||
"browser to complete",
|
||||
"enter the code",
|
||||
"enter code",
|
||||
"verification code",
|
||||
"authorization code",
|
||||
"one-time code",
|
||||
"device code",
|
||||
"oauth",
|
||||
"go to the following",
|
||||
"navigate to the following",
|
||||
"paste the code",
|
||||
]
|
||||
.iter()
|
||||
.any(|pattern| normalized.contains(pattern));
|
||||
|
||||
// 仅 auth 短语命中才转 pending(超时前早退,不构成 deadline 绕过)。
|
||||
// 此前的 `|| (interactive && !output.trim().is_empty())` 会在 interactive=true
|
||||
// 时对任意输出转 pending,绕过超时,与严格硬超时冲突,已移除。
|
||||
has_auth_phrase
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_available_chunks(
|
||||
@ -308,10 +279,6 @@ async fn drain_available_chunks(
|
||||
stdout_buf.lock().await.push_str(&chunk);
|
||||
}
|
||||
}
|
||||
let mut stdout_guard = stdout_buf.lock().await;
|
||||
cap_output_buffer(&mut stdout_guard);
|
||||
let mut stderr_guard = stderr_buf.lock().await;
|
||||
cap_output_buffer(&mut stderr_guard);
|
||||
}
|
||||
|
||||
impl Default for BashTool {
|
||||
@ -413,14 +380,18 @@ impl Tool for BashTool {
|
||||
let timeout_secs = extract_u64(&args, "timeout")
|
||||
.unwrap_or(self.timeout_secs)
|
||||
.clamp(1, MAX_TIMEOUT_SECS); // 下界 1 防止 timeout:0 误杀刚 spawn 的子进程;上界 600s(与 schema minimum/maximum 对齐)
|
||||
let interactive = extract_bool(&args, "interactive").unwrap_or(false);
|
||||
|
||||
let cwd = self
|
||||
.working_dir
|
||||
.as_ref()
|
||||
.map(Path::new)
|
||||
.map(|d| Path::new(d))
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
|
||||
match self.run_command(command, cwd, timeout_secs).await {
|
||||
match self
|
||||
.run_command(command, cwd, timeout_secs, interactive)
|
||||
.await
|
||||
{
|
||||
Ok(output) => Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
@ -439,17 +410,15 @@ impl BashTool {
|
||||
/// 强制终止子进程并回收,避免 `wait()` 永久挂起导致超时未生效。
|
||||
///
|
||||
/// `start_kill` 在 Unix 发 SIGKILL、在 Windows 调 TerminateProcess(均为强制终止)。
|
||||
/// 用 `timeout` 包裹 `wait()` 防止 reap 在异常情况下永久挂起;5s 内未回收则
|
||||
/// 重发终止信号(`start_kill` 非阻塞;不用 `kill().await`——其内部无超时地
|
||||
/// await wait(),在进程被外部挂起/保护时会永久阻塞),再等 3s 兜底,
|
||||
/// 保证工具调用必然返回。
|
||||
/// 用 `timeout` 包裹 `wait()` 防止 reap 在异常情况下永久挂起;5s 内未回收则再
|
||||
/// `kill().await`(重发信号并等待)+ 3s 兜底,保证工具调用必然返回。
|
||||
async fn kill_and_reap(child: &mut tokio::process::Child) {
|
||||
let _ = child.start_kill();
|
||||
if tokio::time::timeout(Duration::from_secs(5), child.wait())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = child.start_kill();
|
||||
let _ = child.kill().await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await;
|
||||
}
|
||||
}
|
||||
@ -459,6 +428,7 @@ impl BashTool {
|
||||
command: &str,
|
||||
cwd: &Path,
|
||||
timeout_secs: u64,
|
||||
interactive: bool,
|
||||
) -> Result<String, String> {
|
||||
let mut cmd = Command::new(self.shell.executable());
|
||||
cmd.args(self.shell.command_args(command))
|
||||
@ -487,9 +457,6 @@ impl BashTool {
|
||||
|
||||
let stdout_buf = Arc::new(Mutex::new(String::new()));
|
||||
let stderr_buf = Arc::new(Mutex::new(String::new()));
|
||||
// pending 短语增量检测窗口:仅保留最近输出的小写化尾部,
|
||||
// 每个 chunk 只扫描"窗口 + 新 chunk"(O(chunk)),不再全量重扫(O(累计输出))。
|
||||
let mut pending_window = String::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
||||
|
||||
loop {
|
||||
@ -514,19 +481,13 @@ impl BashTool {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let mut stdout_guard = stdout_buf.lock().await;
|
||||
cap_output_buffer(&mut stdout_guard);
|
||||
let mut stderr_guard = stderr_buf.lock().await;
|
||||
cap_output_buffer(&mut stderr_guard);
|
||||
// 终止可能仍阻塞在 reader.read() 上的 read_stream 任务,避免泄漏。
|
||||
for t in &read_tasks {
|
||||
t.abort();
|
||||
}
|
||||
// 注意:直接复用上方已持有的 guard。tokio::sync::Mutex 不可重入,
|
||||
// 此处若再次 lock().await 会自死锁(同一任务持锁等待自身释放)。
|
||||
let output = format_command_output(
|
||||
&stdout_guard,
|
||||
&stderr_guard,
|
||||
&stdout_buf.lock().await,
|
||||
&stderr_buf.lock().await,
|
||||
Some(status.code().unwrap_or(-1)),
|
||||
);
|
||||
return Ok(self.truncate_output(&output));
|
||||
@ -537,25 +498,14 @@ impl BashTool {
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => {
|
||||
{
|
||||
let mut buf = if is_stderr {
|
||||
stderr_buf.lock().await
|
||||
if is_stderr {
|
||||
stderr_buf.lock().await.push_str(&chunk);
|
||||
} else {
|
||||
stdout_buf.lock().await
|
||||
};
|
||||
buf.push_str(&chunk);
|
||||
cap_output_buffer(&mut buf);
|
||||
stdout_buf.lock().await.push_str(&chunk);
|
||||
}
|
||||
|
||||
// 增量 pending 检测:交互提示只出现在输出尾部,只扫描
|
||||
// "尾部窗口 + 新 chunk",避免对全量输出做 O(n²) 重扫
|
||||
pending_window.push_str(&chunk.to_lowercase());
|
||||
if pending_window.len() > PENDING_WINDOW_BYTES * 2 {
|
||||
let cut = pending_window
|
||||
.floor_char_boundary(pending_window.len() - PENDING_WINDOW_BYTES);
|
||||
pending_window.drain(..cut);
|
||||
}
|
||||
if contains_pending_phrase(&pending_window) {
|
||||
let combined = format_command_output(&stdout_buf.lock().await, &stderr_buf.lock().await, None);
|
||||
if self.should_return_pending(interactive, &combined) {
|
||||
let mut rx_val = rx.take().unwrap();
|
||||
drain_available_chunks(&mut rx_val, &stdout_buf, &stderr_buf).await;
|
||||
let combined = format_command_output(&stdout_buf.lock().await, &stderr_buf.lock().await, None);
|
||||
@ -679,7 +629,7 @@ fn format_command_output(stdout: &str, stderr: &str, exit_code: Option<i32>) ->
|
||||
|
||||
if !stderr.trim().is_empty() {
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
output.push_str("\n");
|
||||
}
|
||||
output.push_str("STDERR:\n");
|
||||
output.push_str(stderr);
|
||||
|
||||
@ -432,9 +432,7 @@ fn calc_evaluate(args: &serde_json::Value) -> Result<String, String> {
|
||||
// 表达式可产生非有限结果(如 "1/0" → inf、"0/0" → NaN),
|
||||
// 与 extract_values/extract_f64 的边界策略保持一致:拒绝输出。
|
||||
if !n.is_finite() {
|
||||
return Err(format!(
|
||||
"Expression result is not a finite number: {expression}"
|
||||
));
|
||||
return Err(format!("Expression result is not a finite number: {expression}"));
|
||||
}
|
||||
Ok(format_num(n))
|
||||
})
|
||||
@ -875,7 +873,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ok.success);
|
||||
assert_eq!(ok.output, "295232799039604140847618609643520000000");
|
||||
assert_eq!(
|
||||
ok.output,
|
||||
"295232799039604140847618609643520000000"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -140,16 +140,17 @@ impl Tool for FileWriteTool {
|
||||
};
|
||||
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = resolved.parent()
|
||||
&& !parent.exists()
|
||||
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||
{
|
||||
if let Some(parent) = resolved.parent() {
|
||||
if !parent.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Failed to create parent directory: {}", e)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match std::fs::write(&resolved, content) {
|
||||
Ok(_) => Ok(ToolResult {
|
||||
|
||||
@ -1,22 +1,17 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::text::take_prefix_chars;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
|
||||
/// 未配置响应大小限制时的硬性下载上限(防止无限响应打满内存)。
|
||||
const HARD_DOWNLOAD_CAP_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
pub struct HttpRequestTool {
|
||||
allowed_domains: Vec<String>,
|
||||
max_response_size: usize,
|
||||
timeout_secs: u64,
|
||||
allow_private_hosts: bool,
|
||||
/// 长生命周期 HTTP 客户端(连接池 + TLS 上下文 + 超时配置),构造一次全程复用。
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl HttpRequestTool {
|
||||
@ -26,16 +21,11 @@ impl HttpRequestTool {
|
||||
timeout_secs: u64,
|
||||
allow_private_hosts: bool,
|
||||
) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("valid HTTP client configuration");
|
||||
Self {
|
||||
allowed_domains: normalize_domains(allowed_domains),
|
||||
max_response_size,
|
||||
timeout_secs,
|
||||
allow_private_hosts,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,14 +76,15 @@ impl HttpRequestTool {
|
||||
|
||||
if let Some(obj) = headers.as_object() {
|
||||
for (key, value) in obj {
|
||||
if let Some(str_val) = value.as_str()
|
||||
&& let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes())
|
||||
&& let Ok(val) = reqwest::header::HeaderValue::from_str(str_val)
|
||||
{
|
||||
if let Some(str_val) = value.as_str() {
|
||||
if let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
|
||||
if let Ok(val) = reqwest::header::HeaderValue::from_str(str_val) {
|
||||
header_map.insert(name, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header_map
|
||||
}
|
||||
@ -112,41 +103,6 @@ impl HttpRequestTool {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载字节上限:字符上限 × 4(UTF-8 单字符最多 4 字节)保证字符截断前必然读够;
|
||||
/// 未配置字符上限时使用硬性上限,任何情况下下载量都有界。
|
||||
fn download_byte_limit(&self) -> usize {
|
||||
if self.max_response_size == 0 {
|
||||
HARD_DOWNLOAD_CAP_BYTES
|
||||
} else {
|
||||
self.max_response_size
|
||||
.saturating_mul(4)
|
||||
.min(HARD_DOWNLOAD_CAP_BYTES)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式读取响应体,累计达到 `max_bytes` 即提前中止下载。
|
||||
/// 限制在下载过程中生效(而非全量载入后截断),防止超大响应耗尽内存。
|
||||
async fn read_body_limited(
|
||||
response: reqwest::Response,
|
||||
max_bytes: usize,
|
||||
) -> Result<String, String> {
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
let remaining = max_bytes.saturating_sub(body.len());
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if chunk.len() > remaining {
|
||||
body.extend_from_slice(&chunk[..remaining]);
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&body).into_owned())
|
||||
}
|
||||
|
||||
fn normalize_domains(domains: Vec<String>) -> Vec<String> {
|
||||
@ -353,7 +309,22 @@ impl Tool for HttpRequestTool {
|
||||
|
||||
let headers = self.parse_headers(&headers_val);
|
||||
|
||||
let mut request = self.client.request(method, &url).headers(headers);
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(self.timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Failed to create HTTP client: {}", e)),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut request = client.request(method, &url).headers(headers);
|
||||
|
||||
if let Some(body_str) = body {
|
||||
request = request.body(body_str.to_string());
|
||||
@ -364,12 +335,11 @@ impl Tool for HttpRequestTool {
|
||||
let status = response.status();
|
||||
let status_code = status.as_u16();
|
||||
|
||||
// 流式限长读取:下载量在读取过程中即被约束,超限提前中止
|
||||
let response_text =
|
||||
match read_body_limited(response, self.download_byte_limit()).await {
|
||||
Ok(text) => self.truncate_response(&text),
|
||||
Err(_) => "[Failed to read response body]".to_string(),
|
||||
};
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map(|t| self.truncate_response(&t))
|
||||
.unwrap_or_else(|_| "[Failed to read response body]".to_string());
|
||||
|
||||
let output = format!(
|
||||
"Status: {} {}\n\nResponse Body:\n{}",
|
||||
|
||||
@ -55,8 +55,11 @@ pub fn extract_string(args: &serde_json::Value, key: &str) -> Option<String> {
|
||||
args.get(key).and_then(|v| {
|
||||
if let Some(s) = v.as_str() {
|
||||
Some(s.to_string())
|
||||
} else if let Some(n) = v.as_number() {
|
||||
// Handle case where LLM sends a number but we need a string
|
||||
Some(n.to_string())
|
||||
} else {
|
||||
v.as_number().map(|n| n.to_string())
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::domain::tools::{Tool, ToolFunction};
|
||||
|
||||
@ -24,13 +24,20 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<Arc<dyn ToolTrait>> {
|
||||
self.tools.read().get(name).cloned()
|
||||
self.tools
|
||||
.read()
|
||||
.get(name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Get all registered tools.
|
||||
/// Used for concurrent tool execution when we need to look up tools by name.
|
||||
pub fn get_all(&self) -> Vec<Arc<dyn ToolTrait>> {
|
||||
self.tools.read().values().cloned().collect()
|
||||
self.tools
|
||||
.read()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_definitions(&self) -> Vec<Tool> {
|
||||
@ -49,11 +56,18 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
pub fn has_tools(&self) -> bool {
|
||||
!self.tools.read().is_empty()
|
||||
!self
|
||||
.tools
|
||||
.read()
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
pub fn tool_names(&self) -> Vec<String> {
|
||||
self.tools.read().keys().cloned().collect()
|
||||
self.tools
|
||||
.read()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 创建一个排除指定工具的新 registry 副本
|
||||
@ -66,7 +80,9 @@ impl ToolRegistry {
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let new_registry = ToolRegistry::new();
|
||||
*new_registry.tools.write() = filtered;
|
||||
*new_registry
|
||||
.tools
|
||||
.write() = filtered;
|
||||
new_registry
|
||||
}
|
||||
|
||||
@ -81,7 +97,9 @@ impl ToolRegistry {
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let new_registry = ToolRegistry::new();
|
||||
*new_registry.tools.write() = filtered;
|
||||
*new_registry
|
||||
.tools
|
||||
.write() = filtered;
|
||||
new_registry
|
||||
}
|
||||
}
|
||||
|
||||
@ -338,8 +338,8 @@ fn enrich_target_from_context(
|
||||
_ => return target,
|
||||
};
|
||||
|
||||
if !has_non_empty_string(&object, "channel")
|
||||
&& let Some(channel_name) = context
|
||||
if !has_non_empty_string(&object, "channel") {
|
||||
if let Some(channel_name) = context
|
||||
.channel_name
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
@ -349,9 +349,10 @@ fn enrich_target_from_context(
|
||||
serde_json::Value::String(channel_name.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !has_non_empty_string(&object, "chat_id")
|
||||
&& let Some(chat_id) = context
|
||||
if !has_non_empty_string(&object, "chat_id") {
|
||||
if let Some(chat_id) = context
|
||||
.chat_id
|
||||
.as_ref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
@ -361,6 +362,7 @@ fn enrich_target_from_context(
|
||||
serde_json::Value::String(chat_id.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::Value::Object(object)
|
||||
}
|
||||
|
||||
@ -114,12 +114,11 @@ impl SchemaCleanr {
|
||||
anyhow::bail!("Schema missing required 'type' field");
|
||||
}
|
||||
|
||||
if let Some(Value::String(t)) = obj.get("type")
|
||||
&& t == "object"
|
||||
&& !obj.contains_key("properties")
|
||||
{
|
||||
if let Some(Value::String(t)) = obj.get("type") {
|
||||
if t == "object" && !obj.contains_key("properties") {
|
||||
tracing::warn!("Object schema without 'properties' field may cause issues");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -174,11 +173,11 @@ impl SchemaCleanr {
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf simplification
|
||||
if (obj.contains_key("anyOf") || obj.contains_key("oneOf"))
|
||||
&& let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack)
|
||||
{
|
||||
if obj.contains_key("anyOf") || obj.contains_key("oneOf") {
|
||||
if let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack) {
|
||||
return simplified;
|
||||
}
|
||||
}
|
||||
|
||||
// Build cleaned object
|
||||
let mut cleaned = Map::new();
|
||||
@ -245,14 +244,14 @@ impl SchemaCleanr {
|
||||
return Self::preserve_meta(obj, Value::Object(Map::new()));
|
||||
}
|
||||
|
||||
if let Some(def_name) = Self::parse_local_ref(ref_value)
|
||||
&& let Some(definition) = defs.get(def_name.as_str())
|
||||
{
|
||||
if let Some(def_name) = Self::parse_local_ref(ref_value) {
|
||||
if let Some(definition) = defs.get(def_name.as_str()) {
|
||||
ref_stack.insert(ref_value.to_string());
|
||||
let cleaned = Self::clean_with_defs(definition.clone(), defs, strategy, ref_stack);
|
||||
ref_stack.remove(ref_value);
|
||||
return Self::preserve_meta(obj, cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!("Cannot resolve $ref: {}", ref_value);
|
||||
Self::preserve_meta(obj, Value::Object(Map::new()))
|
||||
@ -343,18 +342,17 @@ impl SchemaCleanr {
|
||||
if let Some(Value::Null) = obj.get("const") {
|
||||
return true;
|
||||
}
|
||||
if let Some(Value::Array(arr)) = obj.get("enum")
|
||||
&& arr.len() == 1
|
||||
&& matches!(arr[0], Value::Null)
|
||||
{
|
||||
if let Some(Value::Array(arr)) = obj.get("enum") {
|
||||
if arr.len() == 1 && matches!(arr[0], Value::Null) {
|
||||
return true;
|
||||
}
|
||||
if let Some(Value::String(t)) = obj.get("type")
|
||||
&& t == "null"
|
||||
{
|
||||
}
|
||||
if let Some(Value::String(t)) = obj.get("type") {
|
||||
if t == "null" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@ -19,8 +19,6 @@ use tokio::time::Instant;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::tools::bash::cap_output_buffer;
|
||||
|
||||
const SESSION_TIMEOUT_SECS: u64 = 300; // 5 minutes
|
||||
const OUTPUT_WAIT_MS: u64 = 2000;
|
||||
|
||||
@ -74,20 +72,14 @@ impl ShellSessionManager {
|
||||
let stderr_buf = Arc::new(Mutex::new(initial_stderr));
|
||||
|
||||
// Spawn a background task that drains the channel into buffers.
|
||||
// Buffers are capped in size (head+tail retained) so a long-lived
|
||||
// chatty process cannot grow memory without bound during the session TTL.
|
||||
let stdout_clone = stdout_buf.clone();
|
||||
let stderr_clone = stderr_buf.clone();
|
||||
let drain_task = tokio::spawn(async move {
|
||||
while let Some((is_stderr, chunk)) = rx.recv().await {
|
||||
if is_stderr {
|
||||
let mut buf = stderr_clone.lock().await;
|
||||
buf.push_str(&chunk);
|
||||
cap_output_buffer(&mut buf);
|
||||
stderr_clone.lock().await.push_str(&chunk);
|
||||
} else {
|
||||
let mut buf = stdout_clone.lock().await;
|
||||
buf.push_str(&chunk);
|
||||
cap_output_buffer(&mut buf);
|
||||
stdout_clone.lock().await.push_str(&chunk);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -142,9 +134,7 @@ impl ShellSessionManager {
|
||||
return Err("Session stdin is closed".to_string());
|
||||
}
|
||||
|
||||
// Record output length before wait (byte offsets — buffers only grow via
|
||||
// push_str, so a previous end is always a valid char boundary, unless a
|
||||
// buffer-cap trim happened in between, which is handled below).
|
||||
// Record output length before wait
|
||||
let prev_stdout_len = session.stdout_buf.lock().await.len();
|
||||
let prev_stderr_len = session.stderr_buf.lock().await.len();
|
||||
|
||||
@ -164,23 +154,11 @@ impl ShellSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 按字节偏移在锁内直接切片取新增输出:避免对整个缓冲做全量克隆,
|
||||
// 也修复了旧实现"字节长度当字符数 skip"导致多字节输出丢失的问题。
|
||||
let new_stdout = {
|
||||
let buf = session.stdout_buf.lock().await;
|
||||
match buf.get(prev_stdout_len..) {
|
||||
Some(new_part) => new_part.to_string(),
|
||||
// 偏移失效(缓冲被上限截断过):退化为返回截断后的全部内容
|
||||
None => buf.clone(),
|
||||
}
|
||||
};
|
||||
let new_stderr = {
|
||||
let buf = session.stderr_buf.lock().await;
|
||||
match buf.get(prev_stderr_len..) {
|
||||
Some(new_part) => new_part.to_string(),
|
||||
None => buf.clone(),
|
||||
}
|
||||
};
|
||||
let stdout = session.stdout_buf.lock().await.clone();
|
||||
let stderr = session.stderr_buf.lock().await.clone();
|
||||
|
||||
let new_stdout: String = stdout.chars().skip(prev_stdout_len).collect();
|
||||
let new_stderr: String = stderr.chars().skip(prev_stderr_len).collect();
|
||||
|
||||
let mut result = String::new();
|
||||
if !new_stdout.is_empty() {
|
||||
|
||||
@ -211,9 +211,11 @@ impl Tool for SkillManageTool {
|
||||
Err(err) => return Ok(error_result(&err)),
|
||||
}
|
||||
}
|
||||
if reload && let Err(err) = self.skills.reload() {
|
||||
if reload {
|
||||
if let Err(err) = self.skills.reload() {
|
||||
return Ok(error_result(&err));
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"status": "disabled",
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@ -535,11 +535,7 @@ impl DefaultSubAgentRuntime {
|
||||
let inherited = session
|
||||
.parent_topic_id
|
||||
.as_deref()
|
||||
.and_then(|tid| {
|
||||
self.topic_model_selections
|
||||
.as_ref()
|
||||
.and_then(|s| s.get(tid))
|
||||
})
|
||||
.and_then(|tid| self.topic_model_selections.as_ref().and_then(|s| s.get(tid)))
|
||||
.or_else(|| {
|
||||
self.model_selections
|
||||
.as_ref()
|
||||
@ -881,11 +877,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
// 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。
|
||||
// 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝,
|
||||
// 防止 LLM 通过选择被禁子代理绕过限制。
|
||||
if let Some(cap) = &parent_context.parent_capability
|
||||
&& let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name)
|
||||
{
|
||||
if let Some(cap) = &parent_context.parent_capability {
|
||||
if let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name) {
|
||||
return Err(TaskError::InvalidArguments(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 查找子代理定义
|
||||
let def = self
|
||||
@ -1101,7 +1097,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let _ = sub_done_sender.send(result).await;
|
||||
let _ = store.update_pending_subagent_status(&task_id_for_spawn, "failed");
|
||||
let _ =
|
||||
store.update_pending_subagent_status(&task_id_for_spawn, "failed");
|
||||
// _registry_guard drop 时清理 registry 条目
|
||||
return;
|
||||
}
|
||||
@ -1143,7 +1140,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
String::new(),
|
||||
"cancelled".to_string(),
|
||||
),
|
||||
Err(e) => (SubagentStatus::Failed, String::new(), e.to_string()),
|
||||
Err(e) => (
|
||||
SubagentStatus::Failed,
|
||||
String::new(),
|
||||
e.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
// 查询同 topic 下仍未完成的子代理列表
|
||||
@ -1180,8 +1181,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
SubagentStatus::Timeout => "timeout",
|
||||
SubagentStatus::Cancelled => "cancelled",
|
||||
};
|
||||
if let Err(e) = store.update_pending_subagent_status(&task_id_for_spawn, status_str)
|
||||
{
|
||||
if let Err(e) = store.update_pending_subagent_status(&task_id_for_spawn, status_str) {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
task_id = %task_id_for_spawn,
|
||||
@ -1211,13 +1211,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
if let Err(e) = task_repository.save_task_session(&session_done).await {
|
||||
tracing::warn!(error = %e, task_id = %task_id_for_spawn, "Failed to save failed session");
|
||||
}
|
||||
publish_subagent_error(
|
||||
&bus,
|
||||
&session_done,
|
||||
&e.to_string(),
|
||||
&trace_id_owned,
|
||||
)
|
||||
.await;
|
||||
publish_subagent_error(&bus, &session_done, &e.to_string(), &trace_id_owned).await;
|
||||
}
|
||||
}
|
||||
// _registry_guard 在此 drop,确定性清理 cancel_registry 条目
|
||||
@ -1242,9 +1236,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
|
||||
// ===== 同步路径(子代理嵌套或无 sub_done_sender) =====
|
||||
// 9. 执行任务并处理结果
|
||||
let result = self
|
||||
.execute_task(agent, &session, &def, task.prompt.clone())
|
||||
.await;
|
||||
let result = self.execute_task(agent, &session, &def, task.prompt.clone()).await;
|
||||
|
||||
match result {
|
||||
Ok(tool_result) => {
|
||||
@ -1311,11 +1303,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
// 4.1 校验父智能体的子代理策略(白/黑名单)。
|
||||
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
|
||||
// 该子代理的专家,resume 应失败(与 def 被删除即失败的安全语义一致)。
|
||||
if let Some(cap) = &parent_context.parent_capability
|
||||
&& let Err(msg) = cap.check_subagent_allowed(&session.subagent_type)
|
||||
{
|
||||
if let Some(cap) = &parent_context.parent_capability {
|
||||
if let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) {
|
||||
return Err(TaskError::InvalidArguments(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// 4.2 重新解析 def 以应用工具过滤。
|
||||
// 安全要求:def 被删除/禁用时必须失败恢复,而不是降级为完整工具集——
|
||||
@ -1414,11 +1406,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
// token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失)
|
||||
// 不变量 1:条件 UPDATE,仅在 status='running' 时转为 cancelled,
|
||||
// 避免 spawn 已完成的终态被覆盖(completed → cancelled 是非法转换)
|
||||
match self.store.try_update_pending_subagent_status(
|
||||
&record.task_id,
|
||||
"running",
|
||||
"cancelled",
|
||||
) {
|
||||
match self
|
||||
.store
|
||||
.try_update_pending_subagent_status(&record.task_id, "running", "cancelled")
|
||||
{
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
task_id = %record.task_id,
|
||||
@ -1762,15 +1753,24 @@ impl SubagentRuntime {
|
||||
/// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。
|
||||
pub fn reload(&self) -> Result<(), String> {
|
||||
let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd);
|
||||
let mut guard = self.catalog.write();
|
||||
let mut guard = self
|
||||
.catalog
|
||||
.write()
|
||||
;
|
||||
*guard = new_catalog;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出所有子代理(含禁用项),带 disabled_in_scopes
|
||||
pub fn list_with_status(&self) -> Vec<SubagentWithStatus> {
|
||||
let state = self.disable_state.read();
|
||||
let catalog = self.catalog.read();
|
||||
let state = self
|
||||
.disable_state
|
||||
.read()
|
||||
;
|
||||
let catalog = self
|
||||
.catalog
|
||||
.read()
|
||||
;
|
||||
let mut items: Vec<SubagentWithStatus> = catalog
|
||||
.all()
|
||||
.iter()
|
||||
@ -1794,8 +1794,14 @@ impl SubagentRuntime {
|
||||
|
||||
/// 可用子代理名称(过滤禁用项)
|
||||
pub fn available_names(&self) -> Vec<String> {
|
||||
let state = self.disable_state.read();
|
||||
let catalog = self.catalog.read();
|
||||
let state = self
|
||||
.disable_state
|
||||
.read()
|
||||
;
|
||||
let catalog = self
|
||||
.catalog
|
||||
.read()
|
||||
;
|
||||
catalog
|
||||
.names()
|
||||
.into_iter()
|
||||
@ -1805,17 +1811,30 @@ impl SubagentRuntime {
|
||||
|
||||
/// 查找可用子代理(过滤禁用项)
|
||||
pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
|
||||
let state = self.disable_state.read();
|
||||
let state = self
|
||||
.disable_state
|
||||
.read()
|
||||
;
|
||||
if state.is_disabled(name) {
|
||||
return None;
|
||||
}
|
||||
self.catalog.read().find(name).cloned()
|
||||
self.catalog
|
||||
.read()
|
||||
|
||||
.find(name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 生成过滤后的系统索引提示词
|
||||
pub fn system_index_prompt_filtered(&self) -> Option<String> {
|
||||
let state = self.disable_state.read();
|
||||
let catalog = self.catalog.read();
|
||||
let state = self
|
||||
.disable_state
|
||||
.read()
|
||||
;
|
||||
let catalog = self
|
||||
.catalog
|
||||
.read()
|
||||
;
|
||||
let available_defs: Vec<&SubagentDef> = catalog
|
||||
.all()
|
||||
.into_iter()
|
||||
@ -1853,8 +1872,14 @@ impl SubagentRuntime {
|
||||
allowed: Option<&[String]>,
|
||||
denied: &[String],
|
||||
) -> Option<String> {
|
||||
let state = self.disable_state.read();
|
||||
let catalog = self.catalog.read();
|
||||
let state = self
|
||||
.disable_state
|
||||
.read()
|
||||
;
|
||||
let catalog = self
|
||||
.catalog
|
||||
.read()
|
||||
;
|
||||
let available_defs: Vec<&SubagentDef> = catalog
|
||||
.all()
|
||||
.into_iter()
|
||||
@ -1917,7 +1942,13 @@ impl SubagentRuntime {
|
||||
enabled: bool,
|
||||
) -> Result<SubagentAvailabilityChange, String> {
|
||||
// 校验子代理存在
|
||||
if self.catalog.read().find(name).is_none() {
|
||||
if self
|
||||
.catalog
|
||||
.read()
|
||||
|
||||
.find(name)
|
||||
.is_none()
|
||||
{
|
||||
return Err(format!("subagent '{}' not found", name));
|
||||
}
|
||||
|
||||
@ -1938,7 +1969,10 @@ impl SubagentRuntime {
|
||||
|
||||
// 更新内存中的 disable_state
|
||||
{
|
||||
let mut state = self.disable_state.write();
|
||||
let mut state = self
|
||||
.disable_state
|
||||
.write()
|
||||
;
|
||||
match scope {
|
||||
SubagentScope::User => {
|
||||
if enabled {
|
||||
@ -1958,7 +1992,10 @@ impl SubagentRuntime {
|
||||
}
|
||||
|
||||
// 计算新的 disabled_in_scopes
|
||||
let state = self.disable_state.read();
|
||||
let state = self
|
||||
.disable_state
|
||||
.read()
|
||||
;
|
||||
let disabled_in_scopes = state.disabled_scopes_for(name);
|
||||
|
||||
Ok(SubagentAvailabilityChange {
|
||||
@ -1986,7 +2023,10 @@ impl SubagentRuntime {
|
||||
reload: bool,
|
||||
) -> Result<SubagentDef, String> {
|
||||
let def = {
|
||||
let catalog = self.catalog.read();
|
||||
let catalog = self
|
||||
.catalog
|
||||
.read()
|
||||
;
|
||||
catalog
|
||||
.find(name)
|
||||
.ok_or_else(|| format!("subagent '{}' not found", name))?
|
||||
@ -2049,7 +2089,10 @@ impl SubagentRuntime {
|
||||
) -> Result<SubagentDef, String> {
|
||||
validate_subagent_name(name)?;
|
||||
{
|
||||
let catalog = self.catalog.read();
|
||||
let catalog = self
|
||||
.catalog
|
||||
.read()
|
||||
;
|
||||
if catalog.find(name).is_some() {
|
||||
return Err(format!("subagent '{}' already exists", name));
|
||||
}
|
||||
@ -2093,10 +2136,17 @@ impl SubagentRuntime {
|
||||
/// 对齐 `ExpertRuntime::delete_expert`。
|
||||
/// - builtin 子代理(path 为 None)禁止删除。
|
||||
/// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。
|
||||
pub fn delete_subagent(&self, name: &str, reload: bool) -> Result<PathBuf, String> {
|
||||
pub fn delete_subagent(
|
||||
&self,
|
||||
name: &str,
|
||||
reload: bool,
|
||||
) -> Result<PathBuf, String> {
|
||||
validate_subagent_name(name)?;
|
||||
let path = {
|
||||
let catalog = self.catalog.read();
|
||||
let catalog = self
|
||||
.catalog
|
||||
.read()
|
||||
;
|
||||
let def = catalog
|
||||
.find(name)
|
||||
.ok_or_else(|| format!("subagent '{}' not found", name))?;
|
||||
@ -2151,7 +2201,11 @@ fn validate_subagent_name(name: &str) -> Result<(), String> {
|
||||
|
||||
/// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。
|
||||
/// 对齐 `expert_file_path`。
|
||||
fn subagent_file_path(scope: SubagentScope, name: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||||
fn subagent_file_path(
|
||||
scope: SubagentScope,
|
||||
name: &str,
|
||||
cwd: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
let root = match scope {
|
||||
SubagentScope::User => dirs::home_dir()
|
||||
.map(|p| p.join(".picobot").join("subagents"))
|
||||
@ -2578,7 +2632,7 @@ mod tests {
|
||||
|
||||
// 禁用后 prompt 不应包含 general(无可用子代理时返回 None)
|
||||
let prompt = runtime.system_index_prompt_filtered();
|
||||
assert!(prompt.is_none_or(|p| !p.contains("<name>general</name>")));
|
||||
assert!(prompt.map_or(true, |p| !p.contains("<name>general</name>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -3088,7 +3142,10 @@ mod tests {
|
||||
let item = items.iter().find(|i| i.name == "demo-create").unwrap();
|
||||
assert_eq!(item.description, "demo create agent");
|
||||
assert_eq!(item.body.as_deref(), Some("demo body content"));
|
||||
assert_eq!(item.capability.denied_skills, vec!["skill_x".to_string()]);
|
||||
assert_eq!(
|
||||
item.capability.denied_skills,
|
||||
vec!["skill_x".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -3231,8 +3288,7 @@ mod tests {
|
||||
"directory should be preserved when it has other files"
|
||||
);
|
||||
assert!(
|
||||
!temp
|
||||
.path()
|
||||
!temp.path()
|
||||
.join(".picobot")
|
||||
.join("subagents")
|
||||
.join("mixed")
|
||||
|
||||
@ -103,7 +103,7 @@ impl Tool for TaskTool {
|
||||
|
||||
// 2. 验证描述长度
|
||||
let word_count = task_args.description.split_whitespace().count();
|
||||
if task_args.description.len() > 50 || !(1..=7).contains(&word_count) {
|
||||
if task_args.description.len() > 50 || word_count > 7 || word_count < 1 {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
@ -136,9 +136,8 @@ impl Tool for TaskTool {
|
||||
|
||||
// 4. 深度校验(仅对嵌套场景生效,None = 不限制)
|
||||
// Some(N) 表示允许最多 N 层嵌套:depth=1 的 agent 可创建 depth=2,但 depth=2 不能再创建
|
||||
if let Some(max_depth) = self.max_nesting_depth
|
||||
&& context.nesting_depth > max_depth
|
||||
{
|
||||
if let Some(max_depth) = self.max_nesting_depth {
|
||||
if context.nesting_depth > max_depth {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
@ -148,6 +147,7 @@ impl Tool for TaskTool {
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行任务
|
||||
let result = if let Some(task_id) = task_args.task_id {
|
||||
|
||||
@ -8,10 +8,8 @@ use crate::utils::current_timestamp;
|
||||
/// 子代理会话状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum TaskSessionState {
|
||||
/// 正在执行
|
||||
#[default]
|
||||
Running,
|
||||
/// 已完成
|
||||
Completed,
|
||||
@ -25,6 +23,12 @@ pub enum TaskSessionState {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for TaskSessionState {
|
||||
fn default() -> Self {
|
||||
Self::Running
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskSessionState {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
|
||||
@ -88,12 +88,12 @@ impl Tool for TodoReadTool {
|
||||
// 2. 读锁查内存
|
||||
{
|
||||
let guard = self.state.read().await;
|
||||
if let Some(items) = guard.get(&scope_key)
|
||||
&& !items.is_empty()
|
||||
{
|
||||
if let Some(items) = guard.get(&scope_key) {
|
||||
if !items.is_empty() {
|
||||
return Ok(success_result(items, &scope_key, "memory"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 内存为空 → 查 SQLite 并回填
|
||||
let records = match self.repository.list_todos(&scope_key) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
@ -58,7 +58,11 @@ pub trait WaitCoordinator: Send + Sync + 'static {
|
||||
/// select! 立即返回 `WaitEvent::Cancelled`,并完成完整的状态清理
|
||||
///(重获取锁、回填 guard、清除 is_waiting、归还 receiver)。
|
||||
/// 为 None 时退化为不检查取消(向后兼容,子代理场景)。
|
||||
async fn wait(&self, timeout: Duration, cancel_rx: Option<watch::Receiver<()>>) -> WaitEvent;
|
||||
async fn wait(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
cancel_rx: Option<watch::Receiver<()>>,
|
||||
) -> WaitEvent;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
|
||||
@ -156,7 +156,9 @@ impl Tool for WaitForSubagentsTool {
|
||||
// 传入 cancel_rx 使 /stop 命令能立即中断等待。
|
||||
// coordinator 在 select! 中以 biased 优先级处理:
|
||||
// 子代理结果 > 用户消息 > 取消信号 > 超时
|
||||
let event = coordinator.wait(timeout, context.cancel_rx.clone()).await;
|
||||
let event = coordinator
|
||||
.wait(timeout, context.cancel_rx.clone())
|
||||
.await;
|
||||
|
||||
// 5. 格式化返回结果
|
||||
let output = match event {
|
||||
|
||||
@ -18,7 +18,7 @@ fn test_message_special_characters() {
|
||||
/// Test that multi-line system prompt is preserved
|
||||
#[test]
|
||||
fn test_multiline_system_prompt() {
|
||||
let messages = [
|
||||
let messages = vec![
|
||||
Message::system(
|
||||
"You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate",
|
||||
),
|
||||
|
||||
@ -423,16 +423,6 @@ function App() {
|
||||
sendMessage({ type: 'command', payload: JSON.stringify(cmd) });
|
||||
}, [sendMessage, handleCommand, handleStop]);
|
||||
|
||||
// 稳定引用:只读视图(子智能体/定时任务)下的空发送回调,
|
||||
// 避免内联箭头函数每次渲染产生新引用、破坏下游 memo 化。
|
||||
const noopSendMessage = useCallback(() => {}, []);
|
||||
|
||||
// 稳定引用:打开专家设置页
|
||||
const openExpertsSettings = useCallback(() => {
|
||||
setConfigInitialTab('experts');
|
||||
setConfigPageOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateTopic = useCallback(() => {
|
||||
if (isReadOnly || !sessionId) {
|
||||
return;
|
||||
@ -1017,7 +1007,7 @@ function App() {
|
||||
channels.find((c) => c.id === selectedChannel)?.name ??
|
||||
'PicoBot')
|
||||
}
|
||||
onSendMessage={subAgentView || schedulerView ? noopSendMessage : handleSendMessage}
|
||||
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
|
||||
onNavigateToSubAgent={handleNavigateToSubAgent}
|
||||
onStop={handleStopExecution}
|
||||
showThinking={showThinking}
|
||||
@ -1025,7 +1015,10 @@ function App() {
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
sessionId={sessionId}
|
||||
settingsClosedTick={settingsClosedTick}
|
||||
onOpenSettings={openExpertsSettings}
|
||||
onOpenSettings={() => {
|
||||
setConfigInitialTab('experts');
|
||||
setConfigPageOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { MessageList } from './MessageList';
|
||||
import { MessageInput } from './MessageInput';
|
||||
import { ExpertSelector } from './ExpertSelector';
|
||||
@ -55,13 +55,6 @@ export function ChatContainer({
|
||||
model: string;
|
||||
} | null>(null);
|
||||
|
||||
// 稳定引用,避免内联箭头破坏下游 memo 化
|
||||
const handleModelSelectionChange = useCallback(
|
||||
(effective: { provider: string; model: string }) =>
|
||||
setEffectiveModel({ provider: effective.provider, model: effective.model }),
|
||||
[],
|
||||
);
|
||||
|
||||
const selectors = (
|
||||
<div className="flex flex-wrap items-center gap-1 px-3 pt-2">
|
||||
<ExpertSelector
|
||||
@ -74,7 +67,9 @@ export function ChatContainer({
|
||||
sessionId={sessionId ?? null}
|
||||
topicId={topicId ?? null}
|
||||
settingsClosedTick={settingsClosedTick}
|
||||
onSelectionChange={handleModelSelectionChange}
|
||||
onSelectionChange={(effective) =>
|
||||
setEffectiveModel({ provider: effective.provider, model: effective.model })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -132,24 +132,6 @@ function formatDuration(ms: number): string {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* base64 → Blob 分块解码。
|
||||
* 旧实现为 atob 全量字符串 + 装箱数字数组 + 单个巨型 Uint8Array,
|
||||
* 50MB 附件下载瞬时占用数百 MB;分块构造后 Blob 直接接收分片,
|
||||
* 峰值内存约为 atob 字符串 + 单个分块大小。
|
||||
*/
|
||||
function base64ToBlob(base64: string, mimeType: string): Blob {
|
||||
const byteChars = atob(base64);
|
||||
const total = byteChars.length;
|
||||
const CHUNK_SIZE = 0x8000; // 32K
|
||||
const parts: Uint8Array<ArrayBuffer>[] = [];
|
||||
for (let offset = 0; offset < total; offset += CHUNK_SIZE) {
|
||||
const slice = byteChars.slice(offset, offset + CHUNK_SIZE);
|
||||
parts.push(Uint8Array.from(slice, (c) => c.charCodeAt(0)));
|
||||
}
|
||||
return new Blob(parts, { type: mimeType });
|
||||
}
|
||||
|
||||
function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||||
const fileName = attachment.file_name || getFileName(attachment.path);
|
||||
|
||||
@ -158,7 +140,13 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
|
||||
|
||||
e.preventDefault();
|
||||
const mimeType = attachment.mime_type || 'application/octet-stream';
|
||||
const blob = base64ToBlob(attachment.content_base64, mimeType);
|
||||
const byteChars = atob(attachment.content_base64);
|
||||
const byteNums = new Array(byteChars.length);
|
||||
for (let i = 0; i < byteChars.length; i++) {
|
||||
byteNums[i] = byteChars.charCodeAt(i);
|
||||
}
|
||||
const byteArr = new Uint8Array(byteNums);
|
||||
const blob = new Blob([byteArr], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
@ -222,7 +210,13 @@ function ImageLightbox({
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const blob = base64ToBlob(src, mimeType);
|
||||
const byteChars = atob(src);
|
||||
const byteNums = new Array(byteChars.length);
|
||||
for (let i = 0; i < byteChars.length; i++) {
|
||||
byteNums[i] = byteChars.charCodeAt(i);
|
||||
}
|
||||
const byteArr = new Uint8Array(byteNums);
|
||||
const blob = new Blob([byteArr], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import type { ChatMessage } from '../../types/protocol';
|
||||
@ -55,13 +55,8 @@ export function MessageList({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位。
|
||||
// useMemo 化:仅在 messages 变化时重建,而非每次渲染(流式期间每帧一次)都全量重建。
|
||||
const messageIdToIndex = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
messages.forEach((m, i) => map.set(m.id, i));
|
||||
return map;
|
||||
}, [messages]);
|
||||
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位
|
||||
const messageIdToIndex = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// ---- scroll helpers ----
|
||||
|
||||
@ -172,7 +167,7 @@ export function MessageList({
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedMessageId) return;
|
||||
const idx = messageIdToIndex.get(highlightedMessageId);
|
||||
const idx = messageIdToIndex.current.get(highlightedMessageId);
|
||||
if (idx === undefined) return;
|
||||
|
||||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
|
||||
@ -188,7 +183,7 @@ export function MessageList({
|
||||
targetElement.classList.remove('todo-highlight');
|
||||
}, 2000);
|
||||
});
|
||||
}, [highlightedMessageId, messageIdToIndex, virtualizer]);
|
||||
}, [highlightedMessageId, virtualizer]);
|
||||
|
||||
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
|
||||
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
|
||||
@ -246,6 +241,10 @@ export function MessageList({
|
||||
);
|
||||
}
|
||||
|
||||
// 构建消息 id → index 映射(每次渲染更新,供 highlight 查找)
|
||||
messageIdToIndex.current.clear();
|
||||
messages.forEach((m, i) => messageIdToIndex.current.set(m.id, i));
|
||||
|
||||
// ---- main render ----
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
@ -24,9 +24,7 @@ export function getGatewaySettings(): GatewaySettings {
|
||||
}
|
||||
|
||||
export function buildWsUrl(settings: GatewaySettings): string {
|
||||
// HTTPS 页面下浏览器禁止混合内容(ws://),需使用 wss://
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const base = `${protocol}://${settings.host}:${settings.port}/ws`;
|
||||
const base = `ws://${settings.host}:${settings.port}/ws`;
|
||||
// 远程访问时需要携带认证 token(浏览器原生 WebSocket 不支持自定义 header)
|
||||
const token = getAuthToken();
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
|
||||
@ -55,99 +55,6 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
const subAgentStackRef = useRef<SubAgentView[]>([]);
|
||||
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// ---- 流式 delta 批处理(镜像主视图 useMessages 的 ref 累加 + rAF flush 范式)----
|
||||
// 每个 stream_delta 只累加到 ref,rAF 批量合并为一次 setState,
|
||||
// 避免逐 token 触发整棵子树重渲染与双层数组拷贝。
|
||||
interface PendingSubAgentDelta {
|
||||
/** 'top' 表示栈顶层;否则按 taskId 定位栈中层 */
|
||||
target: 'top' | string;
|
||||
id: string;
|
||||
contentChunks: string[];
|
||||
reasoningChunks: string[];
|
||||
/** 首个 delta 到达时创建的消息壳(含首段 content),后续 delta 追加到其后 */
|
||||
shell: ChatMessage | null;
|
||||
}
|
||||
const pendingDeltasRef = useRef<Map<string, PendingSubAgentDelta>>(new Map());
|
||||
const deltaRafRef = useRef<number | null>(null);
|
||||
const deltaRafScheduledRef = useRef(false);
|
||||
|
||||
const flushSubAgentDeltas = useCallback(() => {
|
||||
deltaRafScheduledRef.current = false;
|
||||
const pending = pendingDeltasRef.current;
|
||||
if (pending.size === 0) return;
|
||||
const entries = Array.from(pending.values());
|
||||
pending.clear();
|
||||
setSubAgentStack((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
let stack = prev;
|
||||
for (const entry of entries) {
|
||||
const layerIdx =
|
||||
entry.target === 'top'
|
||||
? stack.length - 1
|
||||
: stack.findIndex((v) => v.taskId === entry.target);
|
||||
if (layerIdx < 0) continue;
|
||||
const layer = stack[layerIdx];
|
||||
const deltaContent = entry.contentChunks.join('');
|
||||
const deltaReasoning =
|
||||
entry.reasoningChunks.length > 0 ? entry.reasoningChunks.join('') : null;
|
||||
const idx = layer.messages.findIndex((m) => m.id === entry.id && m.type === 'message');
|
||||
const layerCopy = { ...layer };
|
||||
if (idx >= 0) {
|
||||
const updated = [...layer.messages];
|
||||
const existing = updated[idx];
|
||||
updated[idx] = {
|
||||
...existing,
|
||||
content: existing.content + deltaContent,
|
||||
reasoningContent: deltaReasoning
|
||||
? (existing.reasoningContent || '') + deltaReasoning
|
||||
: existing.reasoningContent,
|
||||
};
|
||||
layerCopy.messages = updated;
|
||||
} else if (entry.shell) {
|
||||
layerCopy.messages = [
|
||||
...layer.messages,
|
||||
{
|
||||
...entry.shell,
|
||||
content: entry.shell.content + deltaContent,
|
||||
reasoningContent: deltaReasoning
|
||||
? (entry.shell.reasoningContent || '') + deltaReasoning || undefined
|
||||
: entry.shell.reasoningContent,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const next = [...stack];
|
||||
next[layerIdx] = layerCopy;
|
||||
stack = next;
|
||||
}
|
||||
return stack;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scheduleSubAgentDeltaFlush = useCallback(() => {
|
||||
if (deltaRafScheduledRef.current) return;
|
||||
deltaRafScheduledRef.current = true;
|
||||
deltaRafRef.current = requestAnimationFrame(() => flushSubAgentDeltas());
|
||||
}, [flushSubAgentDeltas]);
|
||||
|
||||
/** 立即落盘待处理 delta(取消已排队的 rAF)。非 delta 消息处理前调用以保证顺序。 */
|
||||
const flushSubAgentDeltasSync = useCallback(() => {
|
||||
if (deltaRafRef.current !== null) {
|
||||
cancelAnimationFrame(deltaRafRef.current);
|
||||
deltaRafRef.current = null;
|
||||
}
|
||||
flushSubAgentDeltas();
|
||||
}, [flushSubAgentDeltas]);
|
||||
|
||||
// 卸载时取消未执行的 flush
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (deltaRafRef.current !== null) cancelAnimationFrame(deltaRafRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ref 同步:确保回调中读到最新值
|
||||
useEffect(() => {
|
||||
subAgentViewRef.current = subAgentView;
|
||||
@ -163,24 +70,33 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
if (message.type === 'assistant_response') {
|
||||
bumpTopicRefreshTrigger();
|
||||
}
|
||||
// stream_delta: 累加到批处理缓冲区,rAF 统一 flush(不再逐 token setState)
|
||||
// stream_delta: accumulate into existing message by ID, or create new
|
||||
if (message.type === 'stream_delta') {
|
||||
const msg = message as StreamDelta;
|
||||
const pending = pendingDeltasRef.current;
|
||||
const entry = pending.get(msg.id);
|
||||
if (entry) {
|
||||
if (msg.delta) entry.contentChunks.push(msg.delta);
|
||||
if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
|
||||
} else {
|
||||
pending.set(msg.id, {
|
||||
target: 'top',
|
||||
id: msg.id,
|
||||
contentChunks: [],
|
||||
reasoningChunks: [],
|
||||
shell: serverMessageToChatMessage(message),
|
||||
});
|
||||
setSubAgentStack((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const top = prev[prev.length - 1];
|
||||
const existingIdx = top.messages.findIndex((m) => m.id === msg.id && m.type === 'message');
|
||||
if (existingIdx >= 0) {
|
||||
const updated = [...top.messages];
|
||||
const existing = updated[existingIdx];
|
||||
updated[existingIdx] = {
|
||||
...existing,
|
||||
content: existing.content + msg.delta,
|
||||
reasoningContent: msg.reasoning_delta
|
||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
||||
: existing.reasoningContent,
|
||||
};
|
||||
const newStack = [...prev];
|
||||
newStack[newStack.length - 1] = { ...top, messages: updated };
|
||||
return newStack;
|
||||
}
|
||||
scheduleSubAgentDeltaFlush();
|
||||
const chatMsg = serverMessageToChatMessage(message);
|
||||
if (!chatMsg) return prev;
|
||||
const newStack = [...prev];
|
||||
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
|
||||
return newStack;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// stream_end: no-op, assistant_response will replace
|
||||
@ -252,31 +168,10 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
return newStack;
|
||||
});
|
||||
}
|
||||
}, [bumpTopicRefreshTrigger, scheduleSubAgentDeltaFlush]);
|
||||
}, [bumpTopicRefreshTrigger]);
|
||||
|
||||
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
|
||||
const appendToSubAgentLayerMessage = useCallback(
|
||||
(taskId: string, message: WsOutbound) => {
|
||||
// stream_delta 在 setState 之外累加到批处理缓冲区(updater 内不允许副作用)
|
||||
if (message.type === 'stream_delta') {
|
||||
const msg = message as StreamDelta;
|
||||
const pending = pendingDeltasRef.current;
|
||||
const entry = pending.get(msg.id);
|
||||
if (entry) {
|
||||
if (msg.delta) entry.contentChunks.push(msg.delta);
|
||||
if (msg.reasoning_delta) entry.reasoningChunks.push(msg.reasoning_delta);
|
||||
} else {
|
||||
pending.set(msg.id, {
|
||||
target: taskId,
|
||||
id: msg.id,
|
||||
contentChunks: [],
|
||||
reasoningChunks: [],
|
||||
shell: serverMessageToChatMessage(message),
|
||||
});
|
||||
}
|
||||
scheduleSubAgentDeltaFlush();
|
||||
return;
|
||||
}
|
||||
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
|
||||
setSubAgentStack((prev) => {
|
||||
const idx = prev.findIndex((v) => v.taskId === taskId);
|
||||
if (idx < 0) return prev;
|
||||
@ -297,11 +192,32 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
type: 'message',
|
||||
};
|
||||
const newStack = [...prev];
|
||||
newStack[idx] = {
|
||||
...layer,
|
||||
status: 'error',
|
||||
messages: [...layer.messages, errorChatMsg],
|
||||
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] };
|
||||
return newStack;
|
||||
}
|
||||
if (message.type === 'stream_delta') {
|
||||
const msg = message as StreamDelta;
|
||||
const existingIdx = layer.messages.findIndex(
|
||||
(m) => m.id === msg.id && m.type === 'message',
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
const updated = [...layer.messages];
|
||||
const existing = updated[existingIdx];
|
||||
updated[existingIdx] = {
|
||||
...existing,
|
||||
content: existing.content + msg.delta,
|
||||
reasoningContent: msg.reasoning_delta
|
||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
||||
: existing.reasoningContent,
|
||||
};
|
||||
const newStack = [...prev];
|
||||
newStack[idx] = { ...layer, messages: updated };
|
||||
return newStack;
|
||||
}
|
||||
const chatMsg = serverMessageToChatMessage(message);
|
||||
if (!chatMsg) return prev;
|
||||
const newStack = [...prev];
|
||||
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
|
||||
return newStack;
|
||||
}
|
||||
if (message.type === 'stream_end') return prev;
|
||||
@ -323,18 +239,14 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
message.type === 'tool_result' ||
|
||||
message.type === 'tool_pending'
|
||||
) {
|
||||
const exists = layer.messages.some(
|
||||
(m) => m.id === chatMsg.id && m.type === chatMsg.type,
|
||||
);
|
||||
const exists = layer.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
|
||||
if (exists) return prev;
|
||||
}
|
||||
const newStack = [...prev];
|
||||
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
|
||||
return newStack;
|
||||
});
|
||||
},
|
||||
[scheduleSubAgentDeltaFlush],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const enterSubAgentView = useCallback(
|
||||
(taskId: string, description: string, subagentType?: string): Command => {
|
||||
@ -355,8 +267,6 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
);
|
||||
|
||||
const exitSubAgentView = useCallback((): Command | null => {
|
||||
// 栈变更前先落盘待处理 delta,避免丢失或写入清空后的新栈
|
||||
flushSubAgentDeltasSync();
|
||||
const current = subAgentStackRef.current;
|
||||
if (current.length <= 1) {
|
||||
subAgentViewRef.current = null;
|
||||
@ -372,12 +282,9 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
subAgentStackRef.current = clearedStack;
|
||||
setSubAgentStack(clearedStack);
|
||||
return { type: 'load_task_messages', task_id: newTop.taskId };
|
||||
}, [flushSubAgentDeltasSync]);
|
||||
}, []);
|
||||
|
||||
const navigateToSubAgentLevel = useCallback(
|
||||
(index: number): Command | null => {
|
||||
// 栈变更前先落盘待处理 delta,避免丢失或写入清空后的新栈
|
||||
flushSubAgentDeltasSync();
|
||||
const navigateToSubAgentLevel = useCallback((index: number): Command | null => {
|
||||
const current = subAgentStackRef.current;
|
||||
if (index < 0) {
|
||||
subAgentViewRef.current = null;
|
||||
@ -394,9 +301,7 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
subAgentStackRef.current = clearedStack;
|
||||
setSubAgentStack(clearedStack);
|
||||
return { type: 'load_task_messages', task_id: newTop.taskId };
|
||||
},
|
||||
[flushSubAgentDeltasSync],
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
|
||||
const handleSubAgentMessage = useCallback(
|
||||
@ -404,11 +309,6 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
const currentSubAgentView = subAgentViewRef.current;
|
||||
if (!currentSubAgentView) return false;
|
||||
|
||||
// 非 delta 消息处理前先落盘待处理的 delta,保证消息顺序与内容完整性
|
||||
if (message.type !== 'stream_delta' && pendingDeltasRef.current.size > 0) {
|
||||
flushSubAgentDeltasSync();
|
||||
}
|
||||
|
||||
if (message.type === 'task_messages_loaded') {
|
||||
const msg = message as TaskMessagesLoaded;
|
||||
setSubAgentStack((prev) => {
|
||||
@ -531,7 +431,6 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
||||
appendToSubAgentLayerMessage,
|
||||
sendCommand,
|
||||
requestSubAgentTodoList,
|
||||
flushSubAgentDeltasSync,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user