Compare commits

..

6 Commits

Author SHA1 Message Date
oudecheng
8a4799656c chore: 升级版本号至 0.4.2 并更新 CHANGELOG 2026-08-17 09:59:56 +08:00
oudecheng
5e2bdaf757 perf(web): 子代理 stream_delta rAF 批处理 + 消息槽 memo 化 + base64 分块解码
- useSubAgentView 镜像主视图:delta 累加到 ref,rAF 批量一次 setState,避免逐 token 重渲染
- 所有非 delta 消息处理前同步落盘 pending delta,保证顺序与内容完整性
- MessageList 消息 ID 映射 useMemo,稳定回调引用减少重渲染
- MessageBubble base64 下载改分块解码(32K),大附件峰值内存从数百 MB 降为单块级
2026-08-17 09:55:11 +08:00
oudecheng
52f858bfb4 perf(tools): bash 输出增量匹配消除 O(n^2) + 缓冲上限,http_request 复用 Client + 流式限长
- bash 增量窗口扫描 pending 短语,不再全量重扫;输出缓冲设上限(头尾保留)
- 修复 wait 分支 stdout_buf 重复锁导致的 tokio Mutex 自死锁(此前正常命令挂到超时)
- shell_session 沿用 cap_output_buffer 上限并修复截断后偏移兜底
- http_request 复用长生命周期 Client;响应体改为流式限长读取,防超大响应耗尽内存
2026-08-17 09:55:03 +08:00
oudecheng
4517e4a724 perf(db): 热路径同步 SQLite 操作改 spawn_blocking 并定向查询
- processor/session 热点 DB 调用放到 blocking 线程池,避免阻塞 tokio worker
- 定向查询 topic 首条用户消息(DB 侧 LIMIT 1),不再全量加载历史
- 新增 first_user_message_content 访问方法
2026-08-17 09:54:54 +08:00
oudecheng
f2fc5e97ac feat(security): 部署安全加固(排除 token 项)
- 安全响应头中间件:nosniff / X-Frame-Options DENY / Referrer-Policy / CSP / Permissions-Policy,最外层覆盖所有响应
- loopback CORS 由 mirror_request 改为 loopback origin 白名单(原实现会回显任意 Origin,允许恶意网页跨域读取本地网关)
- 新增 gateway.allowed_origins 配置:非 loopback 部署可收紧 CORS,未配置时保持 permissive 并打 warn
- loopback 免认证模式强制 Host 头为 loopback,阻断 DNS rebinding
- loopback 免认证模式 WS Origin 必须为 loopback 来源,防跨站 WebSocket 劫持(CSWSH)
- WS 消息/帧大小显式上限 80MiB/16MiB(顺带修复 50MB 附件 base64 超 tungstenite 默认 64MB 上限的问题)
- HTTP 请求体显式限制 2MB(DefaultBodyLimit)
- 前端 HTTPS 页面自动使用 wss://,避免混合内容拦截 WebSocket
- 新增 10 个安全相关单元测试(host/origin loopback 判定、WS Origin 校验)
2026-08-17 06:47:35 +08:00
oudecheng
1019dbe8cc refactor(code-quality): 清理 clippy 存量告警(unwrap/clone/redundant 等)
- 移除无用克隆与冗余引用,减少不必要内存分配
- 规范 unwrap/expect 使用,修复可提前失败路径
- 修复 anthropic provider llm_timeout_secs 死代码并补全超时日志
- cargo fmt 统一格式
2026-08-16 23:22:22 +08:00
82 changed files with 2303 additions and 1805 deletions

2
Cargo.lock generated
View File

@ -1728,7 +1728,7 @@ dependencies = [
[[package]]
name = "picobot"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"async-trait",

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "0.4.1"
version = "0.4.2"
edition = "2024"
[lints.rust]

View File

@ -2,6 +2,45 @@
本文件记录 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 迭代,聚焦 **前端视觉重构**、**话题级模型选择**、**定时任务可靠性**、**缓存占比统计** 与 **调度器并发** 五大方向。
@ -595,6 +634,7 @@
- 前端静态文件嵌入二进制。
- 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

View File

@ -337,15 +337,14 @@ fn filter_images_by_age_and_count(
.count();
let content = if original_image_count > filtered_image_count {
let notice = if exceeds_age_limit {
if exceeds_age_limit {
format!(
"{} [图片已过期:超出 {} 条消息范围]",
message.content, max_age_rounds
)
} else {
format!("{} [图片已过期:超出最大图片数量限制]", message.content)
};
notice
}
} else {
message.content.clone()
};
@ -614,7 +613,7 @@ impl LoopDetector {
.count();
// Warn every warn_every times
if consecutive > 0 && consecutive % self.config.warn_every == 0 {
if consecutive > 0 && consecutive.is_multiple_of(self.config.warn_every) {
LoopDetectionResult::Warning(format!(
"注意: 工具 '{}' 已连续执行 {} 次,参数相同。如果任务没有进展,请尝试其他方法。",
last.name, consecutive
@ -1139,7 +1138,7 @@ impl AgentLoop {
// 避免每轮 serde_json::to_string 全量序列化工具定义。
let tools_tokens = tools
.as_ref()
.map(|t| estimate_tokens_from_serialized_json(t))
.map(estimate_tokens_from_serialized_json)
.unwrap_or_default();
for iteration in 0..self.max_iterations {
@ -1513,71 +1512,67 @@ impl AgentLoop {
.and_then(|m| m.usage.as_ref())
.map(|u| u.prompt_tokens);
if let Some(prompt_tokens) = last_prompt_tokens {
if compressor.should_compress_by_usage(prompt_tokens) {
// 阶段 1a工程化压缩截断非子代理 tool 结果,仅改内存)
// 参数内聚到 ContextCompressorAgentLoop 不持有截断 token 数
compressor.truncate_tool_results(&mut messages);
engineering_compaction_applied = true;
if let Some(prompt_tokens) = last_prompt_tokens
&& compressor.should_compress_by_usage(prompt_tokens)
{
// 阶段 1a工程化压缩截断非子代理 tool 结果,仅改内存)
// 参数内聚到 ContextCompressorAgentLoop 不持有截断 token 数
compressor.truncate_tool_results(&mut messages);
engineering_compaction_applied = true;
tracing::info!(
iteration,
prompt_tokens,
threshold = compressor.threshold(),
"Engineering compaction applied (tool results truncated)"
);
// 阶段 1b重新估算判断是否需要 LLM 压缩30% 阈值)
let estimated = crate::agent::context_compressor::estimate_tokens(&messages);
if estimated > compressor.llm_compaction_threshold() {
tracing::info!(
iteration,
prompt_tokens,
threshold = compressor.threshold(),
"Engineering compaction applied (tool results truncated)"
estimated_tokens = estimated,
llm_threshold = compressor.llm_compaction_threshold(),
"LLM compaction triggered (still above 30% after engineering compaction)"
);
// 阶段 1b重新估算判断是否需要 LLM 压缩30% 阈值)
let estimated =
crate::agent::context_compressor::estimate_tokens(&messages);
if estimated > compressor.llm_compaction_threshold() {
tracing::info!(
iteration,
estimated_tokens = estimated,
llm_threshold = compressor.llm_compaction_threshold(),
"LLM compaction triggered (still above 30% after engineering compaction)"
);
// LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop
match compressor
.compress_two_segment_with_provider(
&messages,
self.provider.as_ref(),
)
.await
{
Ok(compressed) => {
// sink 失败时记日志但不中断——内存已压缩DB 未更新
// 下次 process 从 DB 加载时会重新触发压缩
if let Some(sink) = compaction_sink {
if let Err(e) = sink.compact(&compressed).await {
tracing::error!(
error = %e,
iteration,
"CompactionSink compact failed; \
in-memory messages still replaced, DB will be re-compacted next round"
);
}
}
messages = compressed;
compaction_performed = true;
}
Err(e) => {
tracing::warn!(
// LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop
match compressor
.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
{
tracing::error!(
error = %e,
iteration,
"LLM compaction failed; \
falling back to engineering-only compaction (in-memory truncated messages retained)"
"CompactionSink compact failed; \
in-memory messages still replaced, DB will be re-compacted next round"
);
// 不设置 compaction_performedmessages 保持工程化压缩后的状态
}
messages = compressed;
compaction_performed = true;
}
Err(e) => {
tracing::warn!(
error = %e,
iteration,
"LLM compaction failed; \
falling back to engineering-only compaction (in-memory truncated messages retained)"
);
// 不设置 compaction_performedmessages 保持工程化压缩后的状态
}
} else {
tracing::info!(
iteration,
estimated_tokens = estimated,
llm_threshold = compressor.llm_compaction_threshold(),
"Engineering compaction sufficient (under 30%), skipping LLM compaction"
);
}
} else {
tracing::info!(
iteration,
estimated_tokens = estimated,
llm_threshold = compressor.llm_compaction_threshold(),
"Engineering compaction sufficient (under 30%), skipping LLM compaction"
);
}
}
}
@ -2319,14 +2314,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 = vec![ToolCall {
let calls = [ToolCall {
id: "1".to_string(),
name: "test".to_string(),
arguments: serde_json::json!({}),
}];
// If there's only 1 tool, should return false regardless
assert_eq!(calls.len() <= 1, true);
assert!(calls.len() <= 1);
}
#[test]
@ -2619,9 +2614,15 @@ mod tests {
let filtered = filter_images_by_age_and_count(&messages, 10, 3);
// 检查结果
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!(!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_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 的消息图片应被过滤");
@ -3117,7 +3118,7 @@ mod tests {
assert!(
messages
.iter()
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
.all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
"no assistant should have tool_calls remaining"
);
}

View File

@ -54,7 +54,7 @@ fn is_assistant_with_tool_calls(msg: &ChatMessage) -> bool {
&& msg
.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
.is_some_and(|calls| !calls.is_empty())
}
/// Parse a flat message list into atomic units. Orphaned tool results
@ -713,10 +713,8 @@ 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)
@ -1116,8 +1114,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);
@ -1153,7 +1151,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));
}
@ -1257,7 +1255,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);

View File

@ -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" {
if let Some(ref tc_id) = msg.tool_call_id {
resolved_ids.insert(tc_id.clone());
}
if msg.role == "tool"
&& let Some(ref tc_id) = msg.tool_call_id
{
resolved_ids.insert(tc_id.clone());
}
if msg.role == "assistant"
&& msg
.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
.is_some_and(|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,34 +379,31 @@ 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 {
if !remove_indices.contains(&idx) {
tracing::warn!(
message_index = idx,
interrupted_by_index = i,
interrupted_by_role = %m.role,
pending_tool_call_count = pending_tool_ids.len(),
"Removing assistant with tool_calls — tool results \
not immediately following (interrupted by non-tool message)"
);
// Remove this assistant's tool_call_ids from with_parent
// so Phase 2 cleans up the now-orphaned tool messages
if let Some(calls) = messages[idx].tool_calls.as_ref() {
for tc in calls.iter() {
with_parent.remove(&tc.id);
}
if let Some(idx) = pending_assistant_idx
&& !remove_indices.contains(&idx)
{
tracing::warn!(
message_index = idx,
interrupted_by_index = i,
interrupted_by_role = %m.role,
pending_tool_call_count = pending_tool_ids.len(),
"Removing assistant with tool_calls — tool results \
not immediately following (interrupted by non-tool message)"
);
// Remove this assistant's tool_call_ids from with_parent
// so Phase 2 cleans up the now-orphaned tool messages
if let Some(calls) = messages[idx].tool_calls.as_ref() {
for tc in calls.iter() {
with_parent.remove(&tc.id);
}
remove_indices.push(idx);
}
remove_indices.push(idx);
}
pending_tool_ids.clear();
pending_assistant_idx = None;
}
if m.role == "assistant"
&& m.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
if m.role == "assistant" && m.tool_calls.as_ref().is_some_and(|calls| !calls.is_empty())
{
let already_marked = remove_indices.contains(&i);
if !already_marked {
@ -419,32 +416,31 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
.collect();
pending_assistant_idx = Some(i);
}
} 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;
}
} else if m.role == "tool"
&& 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() {
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"
);
if let Some(calls) = messages[idx].tool_calls.as_ref() {
for tc in calls.iter() {
with_parent.remove(&tc.id);
}
}
remove_indices.push(idx);
if !pending_tool_ids.is_empty()
&& let Some(idx) = pending_assistant_idx
&& !remove_indices.contains(&idx)
{
tracing::warn!(
message_index = idx,
"Removing trailing assistant with incomplete immediate tool results"
);
if let Some(calls) = messages[idx].tool_calls.as_ref() {
for tc in calls.iter() {
with_parent.remove(&tc.id);
}
}
remove_indices.push(idx);
}
}
@ -939,7 +935,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(|(left, _), (right, _)| left.cmp(right));
entries.sort_by_key(|(left, _)| *left);
let body = entries
.into_iter()
.map(|(key, value)| {

View File

@ -234,10 +234,10 @@ impl FeishuChannel {
// 1. Check cache
{
let cached = self.tenant_token.read().await;
if let Some(ref token) = *cached {
if Instant::now() < token.refresh_after {
return Ok(token.value.clone());
}
if let Some(ref token) = *cached
&& Instant::now() < token.refresh_after
{
return Ok(token.value.clone());
}
}
@ -1076,10 +1076,10 @@ impl FeishuChannel {
.await?;
// Fetch and prepend quoted message content if this is a reply
if let Some(ref pid) = parent_id {
if let Some(reply_ctx) = self.get_message_content(pid).await {
content = format!("{}\n{}", reply_ctx, content);
}
if let Some(ref pid) = parent_id
&& let Some(reply_ctx) = self.get_message_content(pid).await
{
content = format!("{}\n{}", reply_ctx, content);
}
#[cfg(debug_assertions)]
@ -1532,15 +1532,15 @@ 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() {
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() {
return result.trim().to_string();
}
texts.clear();
if let Some(obj) = val.as_object()
&& 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() {
return result.trim().to_string();
}
texts.clear();
}
}
}
@ -1565,21 +1565,20 @@ 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()) {
if let Some(elements) = card.get("elements").and_then(|e| e.as_array()) {
for el in elements {
extract_element_content(el, &mut texts);
}
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())
{
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()) {
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));
}
}
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())
{
texts.push(format!("title: {}\n", text));
}
let result = texts.join("").trim().to_string();
@ -1734,14 +1733,12 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
None
}
})
}) {
if let Some(children) = children_arr
.as_object()
.and_then(|o| o.get("children"))
.and_then(|c| c.as_array())
{
collect_list_items(children, lines, depth + 1);
}
}) && let Some(children) = children_arr
.as_object()
.and_then(|o| o.get("children"))
.and_then(|c| c.as_array())
{
collect_list_items(children, lines, depth + 1);
}
}
}
@ -2269,138 +2266,6 @@ 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 {
@ -2502,7 +2367,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"
@ -2671,3 +2536,135 @@ 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"));
}
}

View File

@ -18,6 +18,12 @@ 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());

View File

@ -69,9 +69,7 @@ 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 '{}': {}",
@ -419,7 +417,9 @@ 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,8 +432,9 @@ 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 {

View File

@ -209,11 +209,11 @@ impl InitWizard {
"2" => return self.modify_provider(existing).await,
"3" => {
println!("Keeping existing providers.");
return Ok(existing.providers.clone());
Ok(existing.providers.clone())
}
"4" => {
println!("Skipping provider configuration.");
return Ok(existing.providers.clone());
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.");
return Ok(existing.models.clone());
Ok(existing.models.clone())
}
"2" => return self.add_model(existing).await,
"3" => {
println!("Skipping model configuration.");
return Ok(existing.models.clone());
Ok(existing.models.clone())
}
_ => {
println!("Invalid option, keeping existing models.");
return Ok(existing.models.clone());
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.");
return Ok(existing.agents.clone());
Ok(existing.agents.clone())
}
"4" => {
println!("Skipping agent configuration.");
return Ok(existing.agents.clone());
Ok(existing.agents.clone())
}
_ => {
println!("Invalid option, adding new agent.");

View File

@ -42,12 +42,11 @@ 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?;
}
@ -235,12 +234,11 @@ 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) {
if sender.send(Message::Text(text.into())).await.is_err() {
if let Ok(text) = serialize_inbound(&inbound)
&& sender.send(Message::Text(text.into())).await.is_err() {
tracing::error!("Failed to send message to gateway");
break;
}
}
}
}
}

View File

@ -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 {

View File

@ -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()))
}

View File

@ -85,10 +85,10 @@ async fn handle_list_sessions(
));
// 显示描述(如果有)
if let Some(ref desc) = topic.description {
if !desc.is_empty() {
lines.push(format!(" {}", desc));
}
if let Some(ref desc) = topic.description
&& !desc.is_empty()
{
lines.push(format!(" {}", desc));
}
}
@ -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))
}

View File

@ -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()))
}

View File

@ -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()))
}

View File

@ -197,12 +197,12 @@ 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 [") {
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);
}
if let Some(rest) = title.strip_prefix("Subagent [")
&& 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: ")

View File

@ -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()))
}

View File

@ -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)

View File

@ -72,11 +72,12 @@ pub async fn save_session_to_file(
let output_path = resolve_filepath(filepath, &record);
// 创建父目录
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))?;
}
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
// 写入文件
@ -192,7 +193,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,
)
@ -213,16 +214,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()))
}
/// 子智能体任务数据
@ -391,21 +392,21 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
}
// 工具调用
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));
output.push_str(" ```json\n");
let args_json = serde_json::to_string_pretty(&call.arguments)
.unwrap_or_else(|_| call.arguments.to_string());
for line in args_json.lines() {
output.push_str(&format!(" {}\n", line));
}
output.push_str(" ```\n");
if let Some(ref calls) = msg.tool_calls
&& !calls.is_empty()
{
output.push_str("**Tool Calls:**\n\n");
for call in calls {
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
output.push_str(" ```json\n");
let args_json = serde_json::to_string_pretty(&call.arguments)
.unwrap_or_else(|_| call.arguments.to_string());
for line in args_json.lines() {
output.push_str(&format!(" {}\n", line));
}
output.push('\n');
output.push_str(" ```\n");
}
output.push('\n');
}
output.push_str("---\n\n");
@ -560,21 +561,21 @@ pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> Strin
}
// Tool calls
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));
output.push_str(" ```json\n");
let args_json = serde_json::to_string_pretty(&call.arguments)
.unwrap_or_else(|_| call.arguments.to_string());
for line in args_json.lines() {
output.push_str(&format!(" {}\n", line));
}
output.push_str(" ```\n");
if let Some(ref calls) = msg.tool_calls
&& !calls.is_empty()
{
output.push_str("### Tool Calls\n\n");
for call in calls {
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
output.push_str(" ```json\n");
let args_json = serde_json::to_string_pretty(&call.arguments)
.unwrap_or_else(|_| call.arguments.to_string());
for line in args_json.lines() {
output.push_str(&format!(" {}\n", line));
}
output.push('\n');
output.push_str(" ```\n");
}
output.push('\n');
}
// Media refs
@ -621,16 +622,7 @@ 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() {
@ -716,7 +708,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
filepath,
include_all,
include_subagents,
&*self.store,
&self.store,
Some(self.task_repository.as_ref()),
&*self.system_prompt_provider,
)

View File

@ -54,11 +54,12 @@ pub async fn save_topic_to_file(
let output_path = resolve_topic_filepath(filepath, &topic);
// 创建父目录
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))?;
}
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
// 写入文件
@ -138,16 +139,7 @@ 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())])
@ -267,7 +259,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,
@ -282,14 +274,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()))
}

View File

@ -94,13 +94,13 @@ 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 {
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()))?;
}
if let Some(ref session_manager) = handler.session_manager
&& 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
@ -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)]

View File

@ -108,10 +108,7 @@ 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 {

View File

@ -103,13 +103,13 @@ async fn handle_switch_topic(
})?;
// 如果有 SessionManager实际切换话题历史
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()))?;
}
if let Some(ref session_manager) = handler.session_manager
&& 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()))?;
}
// 使用辅助方法获取消息数量
@ -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()))
}

View File

@ -128,7 +128,7 @@ impl Default for CompactionConfig {
}
/// 可观测性配置日志格式、metrics 开关等)
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ObservabilityConfig {
/// 日志输出格式text默认或 json。
/// json 格式便于接入 ELK/Loki 等日志聚合系统。
@ -136,14 +136,6 @@ 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")]
@ -631,6 +623,11 @@ 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)]
@ -941,6 +938,7 @@ impl Default for GatewayConfig {
max_concurrent_requests: default_max_concurrent_requests(),
session_ttl_hours: Some(24),
auth_token: None,
allowed_origins: None,
}
}
}
@ -1984,7 +1982,7 @@ mod tests {
timezone: "Asia/Shanghai".to_string(),
});
assert_eq!(effective_jobs.len(), 3); // 2个内置 + 1个自定义
// 第一个作业:内存维护(被覆盖为禁用)
// 第一个作业:内存维护(被覆盖为禁用)
assert_eq!(effective_jobs[0].id, BUILTIN_MEMORY_MAINTENANCE_JOB_ID);
assert!(!effective_jobs[0].enabled);
assert_eq!(
@ -2305,25 +2303,33 @@ mod tests {
#[test]
fn test_scheduler_schedule_validation_rejects_invalid_values() {
assert!(SchedulerSchedule::Delay { seconds: 0 }
.validate("delay.job")
.is_err());
assert!(SchedulerSchedule::Interval {
seconds: 0,
startup_delay_secs: 0,
}
.validate("interval.job")
.is_err());
assert!(SchedulerSchedule::At {
timestamp: "bad timestamp".to_string(),
}
.validate("at.job")
.is_err());
assert!(SchedulerSchedule::Cron {
expression: "bad cron".to_string(),
}
.validate("cron.job")
.is_err());
assert!(
SchedulerSchedule::Delay { seconds: 0 }
.validate("delay.job")
.is_err()
);
assert!(
SchedulerSchedule::Interval {
seconds: 0,
startup_delay_secs: 0,
}
.validate("interval.job")
.is_err()
);
assert!(
SchedulerSchedule::At {
timestamp: "bad timestamp".to_string(),
}
.validate("at.job")
.is_err()
);
assert!(
SchedulerSchedule::Cron {
expression: "bad cron".to_string(),
}
.validate("cron.job")
.is_err()
);
}
#[test]

View File

@ -63,13 +63,13 @@ impl CapabilityPolicy {
/// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。
pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> {
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 let Some(list) = &self.allowed_subagents
&& !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!(

View File

@ -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,18 +294,13 @@ 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)
}
@ -323,18 +318,12 @@ 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);
@ -361,10 +350,7 @@ 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(
@ -474,10 +460,7 @@ 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())
}
@ -509,9 +492,7 @@ 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 {
@ -533,9 +514,7 @@ 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 {
@ -558,9 +537,7 @@ 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| {
@ -573,9 +550,7 @@ 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| {
@ -586,16 +561,12 @@ 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;
}

View File

@ -3,7 +3,9 @@ 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;
@ -14,10 +16,10 @@ use crate::gateway::tool_prompt_provider::ToolPromptProvider;
use crate::observability::Observer;
use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::PromptInjectionRepository;
use crate::storage::persistent_session_id;
use crate::storage::SessionStore;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::storage::persistent_session_id;
use crate::tools::task::SubagentResult;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator};
/// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。
@ -133,7 +135,10 @@ impl AgentFactory {
/// 构造 ContextCompressor参数内聚到 ContextCompressorCompactionConfig 注入)。
/// AgentLoopin-loop 压缩)和 Sessionsync 兜底压缩)共用此方法,
/// 确保两条压缩路径使用同一套用户配置的压缩参数。
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,
@ -201,21 +206,23 @@ impl AgentFactory {
// 物化:命中 session 级选择且话题无固化值时,将解析后的具体
// (provider, model) 写入 topics 行(持久化 + 内存缓存)
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()));
if let Err(err) =
self.store.update_topic_model(tid, Some(&provider), Some(&model))
{
tracing::warn!(
error = %err,
topic_id = %tid,
"AgentFactory: failed to materialize topic model selection"
);
}
if !from_topic && 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()),
);
if let Err(err) =
self.store
.update_topic_model(tid, Some(&provider), Some(&model))
{
tracing::warn!(
error = %err,
topic_id = %tid,
"AgentFactory: failed to materialize topic model selection"
);
}
}
@ -289,7 +296,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.as_ref().map(|rx| rx.clone());
let cancel_rx_for_context = request.cancel_token.clone();
let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
let compressor = Arc::new(self.build_compressor(&runtime_config));

View File

@ -42,14 +42,14 @@ impl AgentPromptProvider {
/// 记录注入事件
fn record_injection(&self, context: &SystemPromptContext) {
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,
"Failed to mark agent prompt reinjected; injection counter may be inaccurate"
);
}
if let Some(session_id) = &context.session_id
&& let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id)
{
tracing::warn!(
session_id = ?session_id,
error = %e,
"Failed to mark agent prompt reinjected; injection counter may be inaccurate"
);
}
}
}

View File

@ -1,16 +1,17 @@
//! 网关认证与访问控制。
//!
//! 设计目标(第一性原理):
//! - 本地单机部署host 为 loopback免认证仅靠 CORS 防御 DNS rebinding / CSRF。
//! - 本地单机部署host 为 loopback免认证靠 Host 头 loopback 校验(防 DNS rebinding
//! + CORS loopback origin 白名单(防跨域读取)+ WS Origin loopback 校验(防 CSWSH
//! - 远程访问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};
use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use subtle::ConstantTimeEq;
@ -88,11 +89,7 @@ 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 需要认证;其余放行
@ -126,6 +123,85 @@ pub struct AuthConfig {
pub token: Option<String>,
}
/// 提取 Origin 头的 authorityhost[: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::*;
@ -242,4 +318,88 @@ 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.1Origin 为公网域名,拒绝
assert!(!ws_origin_loopback(&make_origin_host_headers(
Some("http://evil.com"),
Some("127.0.0.1:19876")
)));
// DNS rebindingHost/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")
)));
}
}

View File

@ -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).as_deref() == Some(orig_tid),
Some(orig_tid) => session.current_topic(request.chat_id) == Some(orig_tid),
None => true, // 无 topic 时总是视为当前回合
};
@ -419,7 +419,11 @@ 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 点击跳转能匹配
@ -605,7 +609,11 @@ 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

View File

@ -65,19 +65,19 @@ fn mask_config(config: &Config) -> Config {
}
}
for channel in masked.channels.values_mut() {
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);
}
if let Some(feishu) = channel.as_feishu_mut()
&& !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 {
if !token.is_empty() {
let visible: String = token.chars().take(4).collect();
masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK));
}
if let Some(ref token) = masked.gateway.auth_token
&& !token.is_empty()
{
let visible: String = token.chars().take(4).collect();
masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK));
}
masked
}
@ -116,28 +116,26 @@ 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) {
if let Some(original) = cfg.providers.get(name) {
provider.api_key = original.api_key.clone();
}
if is_masked_key(&provider.api_key)
&& 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() {
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();
}
}
}
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()
{
feishu.app_secret = original_feishu.app_secret.clone();
}
}
// 保留原始 auth_token若提交的是掩码值
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();
}
if let Some(ref submitted) = new_config.gateway.auth_token
&& is_masked_key(submitted)
{
new_config.gateway.auth_token = cfg.gateway.auth_token.clone();
}
} // read lock released here
@ -243,9 +241,7 @@ 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 => (
@ -1160,27 +1156,27 @@ 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() {
if !config.providers.contains_key(name) {
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("provider '{}' not found in config", name)),
}),
);
}
if let Some(name) = provider.as_ref()
&& !config.providers.contains_key(name)
{
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("provider '{}' not found in config", name)),
}),
);
}
if let Some(name) = model.as_ref() {
if !config.models.contains_key(name) {
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("model '{}' not found in config", name)),
}),
);
}
if let Some(name) = model.as_ref()
&& !config.models.contains_key(name)
{
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("model '{}' not found in config", name)),
}),
);
}
drop(config);
@ -1274,27 +1270,27 @@ pub async fn topic_select_model(
// 校验provider/model 名必须在 config 的 providers/models 表中存在
let config = state.config.read().await;
if let Some(name) = provider.as_ref() {
if !config.providers.contains_key(name) {
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("provider '{}' not found in config", name)),
}),
);
}
if let Some(name) = provider.as_ref()
&& !config.providers.contains_key(name)
{
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("provider '{}' not found in config", name)),
}),
);
}
if let Some(name) = model.as_ref() {
if !config.models.contains_key(name) {
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("model '{}' not found in config", name)),
}),
);
}
if let Some(name) = model.as_ref()
&& !config.models.contains_key(name)
{
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("model '{}' not found in config", name)),
}),
);
}
drop(config);
@ -1320,7 +1316,9 @@ 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);
}
(

View File

@ -707,13 +707,13 @@ pub(crate) fn validate_memory_maintenance_output(
}
// 检查目标 namespace 是否与源一致
if let Some(src_ns) = source_namespaces.iter().next() {
if *src_ns != merge.namespace {
return Err(format!(
"跨 namespace 合并被禁止: {} → {}",
src_ns, merge.namespace
));
}
if let Some(src_ns) = source_namespaces.iter().next()
&& *src_ns != merge.namespace
{
return Err(format!(
"跨 namespace 合并被禁止: {} → {}",
src_ns, merge.namespace
));
}
}
@ -768,7 +768,7 @@ pub(crate) fn apply_memory_maintenance_output(
min_memories_to_keep,
max_merge_per_group,
)
.map_err(|e| AgentError::Other(e))?;
.map_err(AgentError::Other)?;
let all_candidates = plan.candidates.clone();
@ -834,14 +834,14 @@ 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()) {
if deleted_ids.insert(candidate.id.clone()) {
store
.delete_memory("user", scope_key, &candidate.namespace, &candidate.key)
.map_err(|err| {
AgentError::Other(format!("delete low value memory error: {}", err))
})?;
}
if let Some(candidate) = candidates_by_id.get(memory_id.as_str())
&& deleted_ids.insert(candidate.id.clone())
{
store
.delete_memory("user", scope_key, &candidate.namespace, &candidate.key)
.map_err(|err| {
AgentError::Other(format!("delete low value memory error: {}", err))
})?;
}
}

View File

@ -31,6 +31,8 @@ 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;
@ -110,27 +112,34 @@ 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(
agent_prompt_reinject_every,
show_tool_results,
config.time.timezone.clone(),
provider_config,
provider_configs,
skills.clone(),
experts.clone(),
Arc::new(BusSessionMessageSender::new(bus.clone())),
std::collections::HashSet::new(),
config.tools.task.clone(),
config.subagents.clone(),
config.memory_maintenance.clone(),
session_ttl_hours,
mcp_config,
config.mcp_tool_timeout_secs,
Some(bus.clone()),
Arc::new(crate::config::ModelResolver::from_config(&config)),
config.compaction.clone(),
)?;
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(),
provider_config,
provider_configs,
skills.clone(),
experts.clone(),
Arc::new(BusSessionMessageSender::new(bus.clone())),
std::collections::HashSet::new(),
config.tools.task.clone(),
config.subagents.clone(),
config.memory_maintenance.clone(),
session_ttl_hours,
mcp_config,
config.mcp_tool_timeout_secs,
Some(bus.clone()),
Arc::new(crate::config::ModelResolver::from_config(&config)),
config.compaction.clone(),
)?;
// 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态)
tracing::info!(
@ -204,6 +213,57 @@ 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>,
@ -226,7 +286,11 @@ 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");
}
@ -252,7 +316,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?;
@ -282,11 +346,16 @@ pub async fn run(
}
// CLI args override config file values
let (bind_host, bind_port, auth_token) = {
let (bind_host, bind_port, auth_token, allowed_origins) = {
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())
(
h,
p,
cfg.gateway.auth_token.clone(),
cfg.gateway.allowed_origins.clone(),
)
};
// 安全校验:绑定到非 loopback 地址时必须配置 auth_token
@ -400,22 +469,62 @@ pub async fn run(
app.layer(axum::Extension(auth_config))
.layer(middleware::from_fn(auth::require_bearer_auth))
} else {
app
// loopback 免认证模式:强制 Host 头为 loopback阻断 DNS rebinding
// (重绑定后浏览器发送的 Host 为攻击者域名,会被直接拒绝)。
app.layer(middleware::from_fn(auth::require_loopback_host))
};
// CORSloopback 下宽松(仅同源);非 loopback 下允许任意来源(由 auth_token 保护)。
// 不论哪种情况都显式设置以避免浏览器默认行为差异
// CORSloopback 下仅允许 loopback 来源(防恶意网页跨域读取本地网关);
// 非 loopback 下优先使用 allowed_origins 白名单
let cors = if auth::is_loopback_host(&bind_host) {
// 本地开发:同源即可,阻止跨域(防 DNS rebinding
// 本地:放行 localhost/127.0.0.1 任意端口(覆盖同源与 vite dev 等场景),
// 拒绝公网 origin——mirror_request 会回显任意 Origin反而允许跨域读取不可用。
CorsLayer::new()
.allow_origin(tower_http::cors::AllowOrigin::mirror_request())
.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_methods(Any)
.allow_headers(Any)
} else {
// 远程访问:允许跨域,但由 token 保护
CorsLayer::permissive()
// 远程访问:优先 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 "
);
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)
}
};
let app = app.layer(cors);
// 层序:后加的在外层。安全响应头最外层,覆盖所有响应(含 401/CORS 预检)。
let app = app
.layer(cors)
.layer(DefaultBodyLimit::max(HTTP_BODY_LIMIT))
.layer(middleware::from_fn(security_headers));
let addr: std::net::SocketAddr = format!("{}:{}", bind_host, bind_port).parse()?;
let listener = {

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use parking_lot::RwLock;
use std::collections::HashMap;
/// per-session 的用户模型覆盖选择存储。
///
@ -17,9 +17,7 @@ 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 {
@ -29,10 +27,7 @@ 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()
}
}

View File

@ -77,37 +77,25 @@ 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(
name.to_string(),
ChannelSink {
high_tx,
low_tx,
cancel,
},
);
self.channels.write().await.insert(
name.to_string(),
ChannelSink {
high_tx,
low_tx,
cancel,
},
);
}
/// sender task优先消费 high 队列(最终响应),再消费 low 队列(中间过程),
@ -166,11 +154,7 @@ 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 {
@ -419,7 +403,7 @@ mod tests {
return Err(ChannelError::ChannelFull);
}
if (count as u32) < self.fail_first_n {
if count < self.fail_first_n {
return Err(ChannelError::SendError("simulated failure".to_string()));
}
@ -479,19 +463,42 @@ 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]
@ -514,8 +521,12 @@ mod tests {
});
// 先发一条 slow500ms 延迟),紧接着发一条 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 {
@ -524,7 +535,9 @@ 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 {
@ -568,8 +581,12 @@ 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 {
@ -820,7 +837,10 @@ 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,

View File

@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::sync::Arc;
use futures_util::FutureExt;
use parking_lot::Mutex;
use std::collections::HashSet;
use std::sync::Arc;
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::session::{BusToolCallEmitter, SessionManager};
use super::message_prepare::enrich_user_content_with_media_refs;
use super::session::{BusToolCallEmitter, SessionManager};
#[derive(Clone)]
pub struct InboundProcessor {
@ -180,39 +180,37 @@ 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(
&trace_id,
&chat_id_for_span,
&session_id_for_span,
async move {
let _permit = permit; // 持有 permit 直到任务完成
// catch_unwind 将 panic 归一化为错误:否则工具/历史清理中的
// panic 只会终止任务并打 panic hook 日志,跳过错误日志与指标,
// 用户消息被静默吞掉。参考 channels/wechat.rs 的同类用法。
let result = std::panic::AssertUnwindSafe(processor.process_one(inbound))
.catch_unwind()
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::error!(
error = %crate::utils::format_error_chain(&e),
"Message processing failed"
);
crate::observability::metrics::record_message_processing_error();
}
Err(payload) => {
tracing::error!(
error = %crate::utils::panic_payload_message(&payload),
"Message processing panicked"
);
crate::observability::metrics::record_message_processing_error();
}
tokio::spawn(crate::observability::tracing_ctx::traced(
&trace_id,
&chat_id_for_span,
&session_id_for_span,
async move {
let _permit = permit; // 持有 permit 直到任务完成
// catch_unwind 将 panic 归一化为错误:否则工具/历史清理中的
// panic 只会终止任务并打 panic hook 日志,跳过错误日志与指标,
// 用户消息被静默吞掉。参考 channels/wechat.rs 的同类用法。
let result = std::panic::AssertUnwindSafe(processor.process_one(inbound))
.catch_unwind()
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::error!(
error = %crate::utils::format_error_chain(&e),
"Message processing failed"
);
crate::observability::metrics::record_message_processing_error();
}
},
),
);
Err(payload) => {
tracing::error!(
error = %crate::utils::panic_payload_message(&payload),
"Message processing panicked"
);
crate::observability::metrics::record_message_processing_error();
}
}
},
));
}
}
@ -280,8 +278,8 @@ impl InboundProcessor {
}
}
}
} else if let Some(error) = response.error {
if let Err(e) = self
} else if let Some(error) = response.error
&& let Err(e) = self
.bus
.publish_outbound(
OutboundMessage::assistant(
@ -295,14 +293,13 @@ impl InboundProcessor {
.with_trace_id(&inbound.trace_id),
)
.await
{
match e {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %e, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %e, "Failed to publish error response");
}
{
match e {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %e, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %e, "Failed to publish error response");
}
}
}
@ -326,73 +323,65 @@ impl InboundProcessor {
//
// 安全性is_waiting 在持锁状态下检查wait_coordinator 清除 is_waiting 需先重获取锁,
// 两者互斥,无 TOCTOU。
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();
if let Some(ref topic_id) = current_topic
&& let Some(session) = self.session_manager.get(&inbound.channel).await
{
let lock_key = topic_id.clone();
// 获取 serial_lock Arc短暂持有 session 锁)
let serial_lock = {
// 获取 serial_lock Arc短暂持有 session 锁)
let serial_lock = {
let mut g = session.lock().await;
g.ensure_sub_done_channel(&lock_key);
g.topic_serial_lock(&lock_key)
};
// 阻塞获取 serial_lock
// - agent 正常运行:阻塞至其完成(天然串行化)
// - agent 在 wait 中wait 已释放锁,可立即获取
let _inject_guard = serial_lock.clone().lock_owned().await;
// 检查 is_waiting持锁状态下安全
let is_waiting = {
let g = session.lock().await;
g.is_waiting(&lock_key)
};
if is_waiting {
// Agent 正在 wait_for_subagents 中等待 → 注入用户消息 + 唤醒
tracing::info!(
topic_id = %lock_key,
"Topic is in waiting state, injecting user message and waking up agent"
);
let wakeup = {
let mut g = session.lock().await;
g.ensure_sub_done_channel(&lock_key);
g.topic_serial_lock(&lock_key)
// 确保 session 和 chat 已加载
g.ensure_persistent_session(&inbound.chat_id)?;
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 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)?;
// 获取 wakeup 信号
g.wait_wakeup(&lock_key)
};
// 阻塞获取 serial_lock
// - agent 正常运行:阻塞至其完成(天然串行化)
// - agent 在 wait 中wait 已释放锁,可立即获取
let _inject_guard = serial_lock.clone().lock_owned().await;
// 唤醒等待中的 agentwait_coordinator 的 select! 会捕获此通知)
wakeup.notify_one();
// 检查 is_waiting持锁状态下安全
let is_waiting = {
let g = session.lock().await;
g.is_waiting(&lock_key)
};
if is_waiting {
// Agent 正在 wait_for_subagents 中等待 → 注入用户消息 + 唤醒
tracing::info!(
topic_id = %lock_key,
"Topic is in waiting state, injecting user message and waking up agent"
);
let wakeup = {
let mut g = session.lock().await;
// 确保 session 和 chat 已加载
g.ensure_persistent_session(&inbound.chat_id)?;
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 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,
)?;
// 获取 wakeup 信号
g.wait_wakeup(&lock_key)
};
// 唤醒等待中的 agentwait_coordinator 的 select! 会捕获此通知)
wakeup.notify_one();
// _inject_guard 在此处 drop → 释放 serial_lock
// wait_coordinator 重获取锁后继续处理history 已包含新用户消息)
//
// 跳过 handle_message / cancel 注册 / execution_completed
// 因为等待中的 agent 会处理这条消息。
return Ok(());
}
// is_waiting=false_inject_guard drop 释放锁,走正常 handle_message 路径
// _inject_guard 在此处 drop → 释放 serial_lock
// wait_coordinator 重获取锁后继续处理history 已包含新用户消息)
//
// 跳过 handle_message / cancel 注册 / execution_completed
// 因为等待中的 agent 会处理这条消息。
return Ok(());
}
// is_waiting=false_inject_guard drop 释放锁,走正常 handle_message 路径
}
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new(
@ -461,81 +450,103 @@ impl InboundProcessor {
// 异步生成 topic 描述(仅当描述为空且没有正在进行的生成任务时触发)
if let Some(ref topic_id) = current_topic {
let store = self.session_manager.store();
if let Ok(Some(topic)) = store.get_topic(topic_id) {
if topic.description.is_none()
// 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()
|| topic
.description
.as_ref()
.map(|d| d.is_empty())
.unwrap_or(true)
{
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
let should_generate = {
let mut in_flight =
self.description_generation_in_flight.lock();
if in_flight.contains(topic_id) {
false
} else {
in_flight.insert(topic_id.clone());
true
}
};
.unwrap_or(true))
{
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
let should_generate = {
let mut in_flight = self.description_generation_in_flight.lock();
if in_flight.contains(topic_id) {
false
} else {
in_flight.insert(topic_id.clone());
true
}
};
if should_generate {
let provider_config = self.provider_config.clone();
let topic_id_clone = topic_id.clone();
let store_clone = store.clone();
let in_flight = self.description_generation_in_flight.clone();
if should_generate {
let provider_config = self.provider_config.clone();
let topic_id_clone = topic_id.clone();
let store_clone = store.clone();
let in_flight = self.description_generation_in_flight.clone();
tokio::spawn(async move {
// 从 DB 查询该 topic 的第一条用户消息作为描述生成的依据
let first_user_message = store_clone
.load_messages_for_topic_full(&topic_id_clone, None)
.ok()
.and_then(|msgs| {
msgs.into_iter().find(|m| m.role == "user")
})
.map(|m| m.content);
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
.ok()
.and_then(|r| r.ok())
.unwrap_or(None);
let message_content = match first_user_message {
Some(content) => content,
None => {
tracing::warn!(topic_id = %topic_id_clone, "No user message found for topic, skipping description generation");
in_flight.lock().remove(&topic_id_clone);
return;
}
};
let message_content = match first_user_message {
Some(content) => content,
None => {
tracing::warn!(topic_id = %topic_id_clone, "No user message found for topic, skipping description generation");
in_flight.lock().remove(&topic_id_clone);
return;
}
};
let runtime_config: ProviderRuntimeConfig =
provider_config.into();
if let Ok(provider) = create_provider(runtime_config) {
match generate_topic_description(
provider.as_ref(),
&message_content,
)
.await
{
Ok(description) => {
if let Err(e) = store_clone
.update_topic_description(
&topic_id_clone,
&description,
let runtime_config: ProviderRuntimeConfig = provider_config.into();
if let Ok(provider) = create_provider(runtime_config) {
match generate_topic_description(
provider.as_ref(),
&message_content,
)
.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,
)
{
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description");
} else {
})
.await;
match update_result {
Ok(Ok(())) => {
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
}
}
Err(e) => {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to generate topic description");
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");
}
}
}
Err(e) => {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to generate topic description");
}
}
// 无论成功失败,释放生成守卫
in_flight.lock().remove(&topic_id_clone);
});
}
}
// 无论成功失败,释放生成守卫
in_flight.lock().remove(&topic_id_clone);
});
}
}
}
@ -588,11 +599,16 @@ 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 {
let pending = self
.session_manager
.store()
.list_pending_subagents(topic_id, Some("running"))
.unwrap_or_default();
// 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())
.unwrap_or_default();
if !pending.is_empty() {
tracing::debug!(
topic_id = %topic_id,

View File

@ -1,4 +1,6 @@
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};
@ -12,10 +14,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;
@ -138,9 +140,29 @@ impl EmittedMessageHandler for BusToolCallEmitter {
}
}
// 拦截 todo_write 结果:即时持久化到 SQLite
// 拦截 todo_write 结果:即时持久化到 SQLite。
// SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker
// await 保持与先前同步实现一致的顺序语义(同一 emitter 的连续
// todo_write 不会乱序覆盖)。
if message.tool_name.as_deref() == Some("todo_write") {
self.persist_todo_write_result(&message);
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");
}
}
}
@ -193,8 +215,14 @@ impl EmittedMessageHandler for BusToolCallEmitter {
}
impl BusToolCallEmitter {
/// 从 todo_write 工具结果中提取 todos 并持久化
fn persist_todo_write_result(&self, message: &ChatMessage) {
/// 从 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,
) {
let parsed: serde_json::Value = match serde_json::from_str(&message.content) {
Ok(v) => v,
Err(_) => return,
@ -204,20 +232,15 @@ impl BusToolCallEmitter {
return;
};
let session_id = crate::storage::persistent_session_id(&self.channel_name, &self.chat_id);
let session_id = crate::storage::persistent_session_id(channel_name, chat_id);
// 优先用 topic_id与 list_todos handler 和 tool 内存状态保持一致)
let scope_key = self
.metadata
let scope_key = metadata
.get("topic_id")
.filter(|t| !t.is_empty())
.cloned()
.unwrap_or_else(|| session_id.clone());
let topic_id = self
.metadata
.get("topic_id")
.filter(|t| !t.is_empty())
.cloned();
let topic_id = metadata.get("topic_id").filter(|t| !t.is_empty()).cloned();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -225,7 +248,7 @@ impl BusToolCallEmitter {
.as_secs() as i64;
// 读取现有 DB 记录,独立对比决定 created_by_message_id 是否更新
let existing = self.store.list_todos(&scope_key).unwrap_or_default();
let existing = 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();
@ -272,7 +295,7 @@ impl BusToolCallEmitter {
"BusToolCallEmitter: persisting todo_write result"
);
if let Err(e) = self.store.replace_todos(&scope_key, &records) {
if let Err(e) = store.replace_todos(&scope_key, &records) {
tracing::warn!(error = %e, %scope_key, "Failed to persist todo list from BusToolCallEmitter");
}
}
@ -557,10 +580,10 @@ impl Session {
}
// 更新 topic 的最后活跃时间
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");
}
if let Some(ref topic_id) = topic_id
&& let Err(e) = self.store.touch_topic(topic_id)
{
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to touch topic");
}
Ok(())
@ -1130,10 +1153,17 @@ impl SessionManager {
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题
if guard.current_topic(chat_id).is_none() {
let session_id = guard.persistent_session_id(chat_id);
let topics = self
.store
.list_topics(&session_id)
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
// 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)))?
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
if let Some(latest_topic) = topics.first() {
// 设置最近活跃的话题为当前话题
@ -1147,8 +1177,14 @@ impl SessionManager {
} else {
// 数据库中也没有话题,自动创建默认话题
let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M"));
match self.store.create_topic(&session_id, &title, None) {
Ok(topic) => {
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)) => {
guard.set_current_topic(chat_id, Some(topic.id.clone()));
tracing::info!(
chat_id = %chat_id,
@ -1158,13 +1194,20 @@ impl SessionManager {
"Auto-created default topic for new chat"
);
}
Err(e) => {
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"
);
}
}
}
}
@ -1270,6 +1313,28 @@ 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::*;
@ -3033,25 +3098,3 @@ 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())),
}
}
}

View File

@ -76,11 +76,7 @@ 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| {
@ -104,10 +100,10 @@ impl SessionHistory {
// 检查是否有活跃 agent 任务serial lock 被持有)
// try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐
// try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐
if let Some(lock) = self.topic_serial_locks.get(*tid) {
if lock.try_lock().is_err() {
return false;
}
if let Some(lock) = self.topic_serial_locks.get(*tid)
&& lock.try_lock().is_err()
{
return false;
}
true
});
@ -176,7 +172,10 @@ 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()
}
@ -334,14 +333,14 @@ impl SessionHistory {
chat_id: &str,
topic_id: Option<&str>,
) -> Result<(), AgentError> {
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");
}
if let Some(tid) = topic_id
&& 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

View File

@ -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",
)],
},

View File

@ -34,14 +34,14 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
None => {
// 对于 SPA 应用,如果请求的是页面路由(不是静态资源),返回 index.html
// 静态资源通常包含 . (如 .js, .css, .png)
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();
}
if !path.contains('.')
&& 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()

View File

@ -11,6 +11,12 @@ 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

View File

@ -111,17 +111,17 @@ impl ToolRegistryFactory {
if self.is_enabled("memory_manage") {
registry.register(MemoryManageTool::new(self.memories.clone()));
}
if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(
state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoReadTool::new(
state.clone(),
self.todo_repository.clone(),
));
}
if self.is_enabled("todo_write")
&& let Some(ref state) = self.todo_state
{
registry.register(TodoWriteTool::new(
state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoReadTool::new(
state.clone(),
self.todo_repository.clone(),
));
}
if self.is_enabled("session_send") {
registry.register(SessionSendTool::new(self.session_message_sender.clone()));
@ -157,15 +157,16 @@ impl ToolRegistryFactory {
}
// 注册 Task 工具(如果启用且有 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 参数覆盖
registry.register(WaitForSubagentsTool::new(
self.task_config.wait_default_timeout_secs,
));
}
if self.is_enabled("task")
&& self.task_config.enabled
&& let Some(runtime) = &self.subagent_runtime
{
registry.register(TaskTool::new(runtime.clone(), None));
// 注册 wait_for_subagents 工具(仅主 agent用于等待异步子代理完成
// 默认超时从配置读取LLM 可通过 timeout_secs 参数覆盖
registry.register(WaitForSubagentsTool::new(
self.task_config.wait_default_timeout_secs,
));
}
registry
@ -230,17 +231,17 @@ impl ToolRegistryFactory {
}
// Todo 追踪工具
if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(
state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoReadTool::new(
state.clone(),
self.todo_repository.clone(),
));
}
if self.is_enabled("todo_write")
&& let Some(ref state) = self.todo_state
{
registry.register(TodoWriteTool::new(
state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoReadTool::new(
state.clone(),
self.todo_repository.clone(),
));
}
// 注册 MCP 工具(如果提供)

View File

@ -97,11 +97,7 @@ 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;

View File

@ -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::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use futures_util::{SinkExt, StreamExt};
@ -46,6 +46,13 @@ use tokio_util::sync::CancellationToken;
const WS_CHANNEL_NAME: &str = "websocket";
/// WebSocket 单条消息大小上限。
/// 前端附件上传上限 50MBbase64 编码后约 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();
@ -137,24 +144,39 @@ 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
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();
}
let mut token_verified = false;
if let Some(axum::Extension(cfg)) = auth_cfg
&& 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;
}
ws.on_upgrade(|socket| async {
handle_socket(socket, state).await;
})
// 无 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 {
handle_socket(socket, state).await;
})
}
async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
@ -450,7 +472,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().clone();
let prompt_repository = state.session_manager.store();
// 与 AgentFactory::create 共享同一构建逻辑,确保 /save、/save-session、
// /current 保存/展示的系统提示词与 LLM 实际接收的完全一致
@ -653,66 +675,61 @@ async fn handle_inbound(
}
// 处理定时任务列表
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") {
if let Ok(jobs) =
if let Some(jobs_json) = response.metadata.get("scheduler_jobs")
&& let Ok(jobs) =
serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json)
{
let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await;
}
{
let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await;
}
// 处理技能列表
if let Some(skills_json) = response.metadata.get("skills") {
if let Ok(skills) =
if let Some(skills_json) = response.metadata.get("skills")
&& let Ok(skills) =
serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json)
{
let _ = sender.send(WsOutbound::SkillList { skills }).await;
}
{
let _ = sender.send(WsOutbound::SkillList { skills }).await;
}
// 处理 Todo 列表
if let Some(todos_json) = response.metadata.get("todos") {
if let Ok(todos) =
if let Some(todos_json) = response.metadata.get("todos")
&& let Ok(todos) =
serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
{
let scope_key = response
.metadata
.get("todos_scope_key")
.cloned()
.unwrap_or_default();
tracing::debug!(todo_count = todos.len(), %scope_key, "list_todos command response");
let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await;
}
{
let scope_key = response
.metadata
.get("todos_scope_key")
.cloned()
.unwrap_or_default();
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") {
if let Ok(memories) =
if let Some(memories_json) = response.metadata.get("memories")
&& let Ok(memories) =
serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json)
{
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
}
{
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
}
// 记忆 CRUD 后自动刷新列表
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") {
if let Ok(records) =
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true")
&& let Ok(records) =
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
{
let memories: Vec<crate::protocol::MemorySummary> = records
.into_iter()
.filter(|m| m.namespace != "_meta")
.map(|m| crate::protocol::MemorySummary {
id: m.id,
namespace: m.namespace,
memory_key: m.memory_key,
content: m.content,
created_at: m.created_at,
updated_at: m.updated_at,
})
.collect();
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
}
{
let memories: Vec<crate::protocol::MemorySummary> = records
.into_iter()
.filter(|m| m.namespace != "_meta")
.map(|m| crate::protocol::MemorySummary {
id: m.id,
namespace: m.namespace,
memory_key: m.memory_key,
content: m.content,
created_at: m.created_at,
updated_at: m.updated_at,
})
.collect();
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
}
// 处理加载聊天消息请求
@ -738,31 +755,29 @@ async fn handle_inbound(
}
}
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();
*current_topic_id = Some(topic_id.clone());
if let Err(e) = send_topic_history(
&store,
current_session_id,
&topic_id,
sender,
&state.task_repository,
)
.await
{
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
}
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) {
Ok(topics) => {
if let Some(first_topic) = topics.first() {
let topic_id = first_topic.topic_id.clone();
*current_topic_id = Some(topic_id.clone());
if let Err(e) = send_topic_history(
&store,
current_session_id,
&topic_id,
sender,
&state.task_repository,
)
.await
{
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
}
}
Err(e) => {
tracing::warn!(error = %e, "Failed to parse topics metadata for initial history");
}
}
Err(e) => {
tracing::warn!(error = %e, "Failed to parse topics metadata for initial history");
}
}
}
@ -820,10 +835,10 @@ 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" {
if let Some(ref tcid) = msg.tool_call_id {
tool_call_ids_with_results.insert(tcid.clone());
}
if msg.role == "tool"
&& let Some(ref tcid) = msg.tool_call_id
{
tool_call_ids_with_results.insert(tcid.clone());
}
}
@ -894,7 +909,8 @@ 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
@ -916,13 +932,15 @@ 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);
}
_ => {} // 不存在(已清理)或仍在运行:保留原占位
}
@ -945,10 +963,10 @@ 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" {
if let Some(ref tcid) = msg.tool_call_id {
tool_call_ids_with_results.insert(tcid.clone());
}
if msg.role == "tool"
&& let Some(ref tcid) = msg.tool_call_id
{
tool_call_ids_with_results.insert(tcid.clone());
}
}
@ -1041,10 +1059,10 @@ 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:") {
if let Some(pos) = parent.find(":task:") {
return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
}
if parent.starts_with("sub:")
&& let Some(pos) = parent.find(":task:")
{
return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
}
None
}

View File

@ -3,7 +3,7 @@ use chrono_tz::Tz;
use std::path::PathBuf;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{
fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
EnvFilter, Layer, fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt,
};
use crate::config::LogFormat;
@ -61,14 +61,14 @@ 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() {
if let Err(e) = std::fs::create_dir_all(&log_dir) {
eprintln!(
"Warning: Failed to create log directory {}: {}",
log_dir.display(),
e
);
}
if !log_dir.exists()
&& 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

View File

@ -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};

View File

@ -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(|s| std::path::PathBuf::from(s)),
cwd: self.cwd.as_ref().map(std::path::PathBuf::from),
})
}
"http" | "streamableHttp" => {

View File

@ -111,25 +111,22 @@ 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,
)
.await
.map_err(|_| {
tracing::warn!(
server_key = %self.server_key,
tool = %self.tool_name,
timeout_secs = self.timeout_secs,
"MCP tool call timed out"
);
anyhow::anyhow!(
"MCP tool '{}' on server '{}' timed out after {}s",
self.tool_name,
self.server_key,
self.timeout_secs
)
})??
tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), call)
.await
.map_err(|_| {
tracing::warn!(
server_key = %self.server_key,
tool = %self.tool_name,
timeout_secs = self.timeout_secs,
"MCP tool call timed out"
);
anyhow::anyhow!(
"MCP tool '{}' on server '{}' timed out after {}s",
self.tool_name,
self.server_key,
self.timeout_secs
)
})??
} else {
call.await?
};
@ -183,12 +180,8 @@ 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(),

View File

@ -40,7 +40,8 @@ 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

View File

@ -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 主目录,若无法确定则回退到当前目录 `"."`。

View File

@ -125,7 +125,6 @@ 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>,
@ -316,15 +315,15 @@ impl LLMProvider for AnthropicProvider {
req_builder = req_builder.header(key.as_str(), value.as_str());
}
let resp = req_builder.json(&body).send().await.map_err(|e| {
let resp = req_builder.json(&body).send().await.inspect_err(|e| {
tracing::error!(
provider = %self.name,
model = %self.model_id,
url = %url,
error = %format_error_chain(&e),
timeout_secs = self.llm_timeout_secs,
error = %format_error_chain(e),
"Anthropic: HTTP request failed"
);
e
})?;
let status = resp.status();
let text = resp.text().await?;
@ -635,7 +634,7 @@ mod tests {
#[test]
fn test_format_error_chain_single() {
let err = std::io::Error::new(std::io::ErrorKind::Other, "single error");
let err = std::io::Error::other("single error");
let chain = format_error_chain(&err);
assert_eq!(chain, "single error");
}
@ -649,7 +648,7 @@ mod tests {
#[test]
fn test_format_error_chain_nested() {
let inner = std::io::Error::new(std::io::ErrorKind::Other, "root cause");
let inner = std::io::Error::other("root cause");
let outer = OuterError::Wrapped(inner);
let chain = format_error_chain(&outer);
assert!(chain.contains("outer wrapper"));

View File

@ -63,22 +63,19 @@ impl StreamingAccumulator {
name: Option<&str>,
arguments: Option<&str>,
) {
let entry = self
.tool_calls
.entry(index)
.or_insert_with(StreamingToolCall::default);
let entry = self.tool_calls.entry(index).or_default();
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
if let Some(id) = id {
if !id.is_empty() {
entry.id = id.to_string();
}
if let Some(id) = id
&& !id.is_empty()
{
entry.id = id.to_string();
}
// 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值
if let Some(name) = name {
if !name.is_empty() {
entry.name = name.to_string();
}
if let Some(name) = name
&& !name.is_empty()
{
entry.name = name.to_string();
}
if let Some(args) = arguments {
entry.arguments.push_str(args);
@ -107,8 +104,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_else(|_| serde_json::Value::Null);
let arguments =
serde_json::from_str(&call.arguments).unwrap_or(serde_json::Value::Null);
ToolCall {
id: call.id,
name: call.name,
@ -218,14 +215,12 @@ fn convert_content_blocks(
}
// 如果只有一个文本块且没有通知,返回字符串形式
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());
}
}
}
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())
{
return Value::String(text.to_string());
}
return Value::Array(converted_blocks);
@ -233,10 +228,10 @@ fn convert_content_blocks(
}
// 原有逻辑 - 模型支持图片,正常转换
if blocks.len() == 1 {
if let ContentBlock::Text { text } = &blocks[0] {
return Value::String(text.clone());
}
if blocks.len() == 1
&& let ContentBlock::Text { text } = &blocks[0]
{
return Value::String(text.clone());
}
Value::Array(
blocks
@ -481,14 +476,12 @@ impl OpenAIProvider {
}
// 提取流式末帧的 usagestream_options.include_usage=true 时返回)
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(usage_val) = json.get("usage")
&& !usage_val.is_null()
&& let Ok(u) =
serde_json::from_value::<OpenAIUsage>(usage_val.clone())
{
accumulator.set_usage(u);
}
// 提取 choices
@ -605,13 +598,11 @@ impl OpenAIProvider {
}
// 提取流式末帧的 usage与主循环一致
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(usage_val) = json.get("usage")
&& !usage_val.is_null()
&& 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()) {
@ -684,53 +675,54 @@ impl OpenAIProvider {
// 回退:当流式解析未获取到任何内容且无 tool call 时,
// 服务器可能返回的是非 SSE 格式的纯 JSON尝试直接反序列化整个响应体
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
if response.content.is_empty()
&& response.tool_calls.is_empty()
&& let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body)
{
let fallback_content = openai_resp
.choices
.first()
.and_then(|c| c.message.content.as_deref())
.unwrap_or("")
.to_string();
if !fallback_content.is_empty() {
tracing::debug!(
model = %self.model_id,
"Streaming accumulator empty, falling back to non-SSE JSON parsing"
);
response.content = fallback_content;
response.reasoning_content = openai_resp
.choices
.first()
.and_then(|c| c.message.content.as_deref())
.unwrap_or("")
.to_string();
if !fallback_content.is_empty() {
tracing::debug!(
model = %self.model_id,
"Streaming accumulator empty, falling back to non-SSE JSON parsing"
);
response.content = fallback_content;
response.reasoning_content = openai_resp
.choices
.first()
.and_then(|c| c.message.reasoning_content.clone());
response.tool_calls = openai_resp
.choices
.first()
.map(|c| {
c.message
.tool_calls
.iter()
.map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: match &tc.function.arguments {
OAIFunctionArguments::Json(args) => args.clone(),
OAIFunctionArguments::String(args) => {
serde_json::from_str(args)
.unwrap_or(serde_json::Value::Null)
}
},
})
.collect()
})
.unwrap_or_default();
// 回退场景下也从非流式响应提取 usage
response.usage = Usage {
prompt_tokens: openai_resp.usage.prompt_tokens,
completion_tokens: openai_resp.usage.completion_tokens,
total_tokens: openai_resp.usage.total_tokens,
cached_tokens: openai_resp.usage.cached_tokens(),
};
}
.and_then(|c| c.message.reasoning_content.clone());
response.tool_calls = openai_resp
.choices
.first()
.map(|c| {
c.message
.tool_calls
.iter()
.map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: match &tc.function.arguments {
OAIFunctionArguments::Json(args) => args.clone(),
OAIFunctionArguments::String(args) => {
serde_json::from_str(args)
.unwrap_or(serde_json::Value::Null)
}
},
})
.collect()
})
.unwrap_or_default();
// 回退场景下也从非流式响应提取 usage
response.usage = Usage {
prompt_tokens: openai_resp.usage.prompt_tokens,
completion_tokens: openai_resp.usage.completion_tokens,
total_tokens: openai_resp.usage.total_tokens,
cached_tokens: openai_resp.usage.cached_tokens(),
};
}
}
@ -761,26 +753,25 @@ impl OpenAIProvider {
std::collections::HashSet::new();
for (i, m) in request.messages.iter().enumerate().rev() {
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 == "tool"
&& let Some(ref tc_id) = m.tool_call_id
{
resolved_tool_ids.insert(tc_id.as_str());
}
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()));
if all_resolved {
for tc in calls {
with_parent.insert(tc.id.as_str());
}
} else {
skip_assistant_indices.insert(i);
}
if m.role == "assistant"
&& let Some(ref calls) = m.tool_calls
&& !calls.is_empty()
{
let all_resolved = calls
.iter()
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
if all_resolved {
for tc in calls {
with_parent.insert(tc.id.as_str());
}
} else {
skip_assistant_indices.insert(i);
}
}
}
@ -827,36 +818,37 @@ impl OpenAIProvider {
}
if m.role == "assistant" {
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);
}
if let Some(ref calls) = m.tool_calls
&& !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" {
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;
}
} else if m.role == "tool"
&& 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() {
if let Some(idx) = pending_assistant_idx {
skip_assistant_indices.insert(idx);
tracing::warn!(
message_index = idx,
pending_tool_call_count = pending_tool_ids.len(),
"build_request_body: trailing assistant tool_calls without \
if !pending_tool_ids.is_empty()
&& let Some(idx) = pending_assistant_idx
{
skip_assistant_indices.insert(idx);
tracing::warn!(
message_index = idx,
pending_tool_call_count = pending_tool_ids.len(),
"build_request_body: trailing assistant tool_calls without \
immediately following tool results stripping tool_calls"
);
if let Some(calls) = &request.messages[idx].tool_calls {
for tc in calls.iter() {
with_parent.remove(tc.id.as_str());
}
);
if let Some(calls) = &request.messages[idx].tool_calls {
for tc in calls.iter() {
with_parent.remove(tc.id.as_str());
}
}
}
@ -956,11 +948,10 @@ impl OpenAIProvider {
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
});
if m.role == "assistant" {
if let Some(reasoning_content) = &m.reasoning_content {
if m.role == "assistant"
&& let Some(reasoning_content) = &m.reasoning_content {
message["reasoning_content"] = Value::String(reasoning_content.clone());
}
}
Some(message)
}
@ -1150,15 +1141,14 @@ 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") {
if let Some(url_str) = item
if item.get("type").and_then(|t| t.as_str()) == Some("image_url")
&& let Some(url_str) = item
.get("image_url")
.and_then(|u| u.get("url"))
.and_then(|v| v.as_str())
{
let prefix: String = url_str.chars().take(20).collect();
tracing::debug!(msg_idx = i, item_idx = j, image_prefix = %prefix, image_url_len = %url_str.len(), "Image in LLM request (first 20 bytes shown)");
}
{
let prefix: String = url_str.chars().take(20).collect();
tracing::debug!(msg_idx = i, item_idx = j, image_prefix = %prefix, image_url_len = %url_str.len(), "Image in LLM request (first 20 bytes shown)");
}
}
}

View File

@ -734,13 +734,13 @@ impl RuntimeJob {
return Ok(());
}
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(());
}
if let Some(max_runs) = self.max_runs
&& 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);

View File

@ -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,9 +143,7 @@ impl SkillRuntime {
}
pub fn is_empty(&self) -> bool {
self.catalog
.read()
.is_empty()
self.catalog.read().is_empty()
}
pub fn len(&self) -> usize {
@ -153,9 +151,7 @@ impl SkillRuntime {
}
pub fn system_index_prompt(&self) -> Option<String> {
self.catalog
.read()
.system_index_prompt()
self.catalog.read().system_index_prompt()
}
/// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。
@ -170,34 +166,23 @@ 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.
@ -226,10 +211,7 @@ 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(
@ -450,7 +432,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()) {
@ -519,7 +501,7 @@ impl SkillCatalog {
.filter(|s| {
allowed_set
.as_ref()
.map_or(true, |set| set.contains(s.name.as_str()))
.is_none_or(|set| set.contains(s.name.as_str()))
})
.collect();

View File

@ -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(

View File

@ -574,9 +574,7 @@ 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?);
@ -1027,7 +1025,7 @@ impl SessionStore {
new_messages.iter().partition(|m| {
m.system_context
.as_deref()
.map_or(false, |sc| sc.starts_with("history_compaction"))
.is_some_and(|sc| sc.starts_with("history_compaction"))
});
// 先删除该 topic 下已有的旧压缩摘要system_context LIKE 'history_compaction%')。
@ -1787,6 +1785,33 @@ 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(*)` 在数据库侧计数,避免将所有消息

View File

@ -36,6 +36,10 @@ 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(
@ -298,6 +302,10 @@ 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,

View File

@ -186,7 +186,9 @@ pub struct MemoryUpsert {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum SchedulerJobState {
#[default]
Scheduled,
Running,
Paused,
@ -241,12 +243,6 @@ impl SchedulerJobStatus {
}
}
impl Default for SchedulerJobState {
fn default() -> Self {
Self::Scheduled
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerJobRecord {
pub id: String,

View File

@ -13,10 +13,16 @@ 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_bool, extract_u64};
use crate::tools::{check_null_args, 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 管道的守护进程
@ -29,6 +35,79 @@ 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。
@ -92,7 +171,7 @@ impl ShellKind {
let info = self.to_info();
info.args
.iter()
.map(|s| *s)
.copied()
.chain(std::iter::once(command))
.collect()
}
@ -121,7 +200,9 @@ impl ShellKind {
pub struct BashTool {
timeout_secs: u64,
working_dir: Option<String>,
deny_patterns: Vec<String>,
/// 危险命令拦截正则:构造时预编译(模式串在构造后不变),
/// 避免每次执行命令都重新编译全部正则。
deny_patterns: Vec<regex::Regex>,
shell: ShellKind,
session_manager: Arc<ShellSessionManager>,
}
@ -131,7 +212,16 @@ impl BashTool {
Self {
timeout_secs: 60,
working_dir: None,
deny_patterns: dangerous_command_patterns(),
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(),
shell: ShellKind::detect(),
session_manager,
}
@ -154,15 +244,11 @@ impl BashTool {
fn guard_command(&self, command: &str) -> Option<String> {
let lower = command.to_lowercase();
for pattern in &self.deny_patterns {
if regex::Regex::new(pattern)
.ok()
.map(|re| re.is_match(&lower))
.unwrap_or(false)
{
for re in &self.deny_patterns {
if re.is_match(&lower) {
return Some(format!(
"Command blocked by safety guard (dangerous pattern: {})",
pattern
re.as_str()
));
}
}
@ -208,63 +294,6 @@ 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(
@ -279,6 +308,10 @@ 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 {
@ -380,18 +413,14 @@ 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(|d| Path::new(d))
.map(Path::new)
.unwrap_or_else(|| Path::new("."));
match self
.run_command(command, cwd, timeout_secs, interactive)
.await
{
match self.run_command(command, cwd, timeout_secs).await {
Ok(output) => Ok(ToolResult {
success: true,
output,
@ -410,15 +439,17 @@ impl BashTool {
/// 强制终止子进程并回收,避免 `wait()` 永久挂起导致超时未生效。
///
/// `start_kill` 在 Unix 发 SIGKILL、在 Windows 调 TerminateProcess均为强制终止
/// 用 `timeout` 包裹 `wait()` 防止 reap 在异常情况下永久挂起5s 内未回收则再
/// `kill().await`(重发信号并等待)+ 3s 兜底,保证工具调用必然返回。
/// 用 `timeout` 包裹 `wait()` 防止 reap 在异常情况下永久挂起5s 内未回收则
/// 重发终止信号(`start_kill` 非阻塞;不用 `kill().await`——其内部无超时地
/// await wait(),在进程被外部挂起/保护时会永久阻塞),再等 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.kill().await;
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(3), child.wait()).await;
}
}
@ -428,7 +459,6 @@ 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))
@ -457,6 +487,9 @@ 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 {
@ -481,13 +514,19 @@ 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_buf.lock().await,
&stderr_buf.lock().await,
&stdout_guard,
&stderr_guard,
Some(status.code().unwrap_or(-1)),
);
return Ok(self.truncate_output(&output));
@ -498,14 +537,25 @@ impl BashTool {
None => std::future::pending().await,
}
} => {
if is_stderr {
stderr_buf.lock().await.push_str(&chunk);
} else {
stdout_buf.lock().await.push_str(&chunk);
{
let mut buf = if is_stderr {
stderr_buf.lock().await
} else {
stdout_buf.lock().await
};
buf.push_str(&chunk);
cap_output_buffer(&mut buf);
}
let combined = format_command_output(&stdout_buf.lock().await, &stderr_buf.lock().await, None);
if self.should_return_pending(interactive, &combined) {
// 增量 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 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);
@ -629,7 +679,7 @@ fn format_command_output(stdout: &str, stderr: &str, exit_code: Option<i32>) ->
if !stderr.trim().is_empty() {
if !output.is_empty() {
output.push_str("\n");
output.push('\n');
}
output.push_str("STDERR:\n");
output.push_str(stderr);

View File

@ -432,7 +432,9 @@ 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))
})
@ -873,10 +875,7 @@ mod tests {
.await
.unwrap();
assert!(ok.success);
assert_eq!(
ok.output,
"295232799039604140847618609643520000000"
);
assert_eq!(ok.output, "295232799039604140847618609643520000000");
}
#[tokio::test]

View File

@ -140,16 +140,15 @@ impl Tool for FileWriteTool {
};
// Create parent directories if needed
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)),
});
}
}
if let Some(parent) = resolved.parent()
&& !parent.exists()
&& 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) {

View File

@ -1,17 +1,22 @@
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 {
@ -21,11 +26,16 @@ 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,
}
}
@ -76,12 +86,11 @@ impl HttpRequestTool {
if let Some(obj) = headers.as_object() {
for (key, value) in obj {
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);
}
}
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)
{
header_map.insert(name, val);
}
}
}
@ -103,6 +112,41 @@ impl HttpRequestTool {
text.to_string()
}
}
/// 下载字节上限:字符上限 × 4UTF-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> {
@ -309,22 +353,7 @@ impl Tool for HttpRequestTool {
let headers = self.parse_headers(&headers_val);
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);
let mut request = self.client.request(method, &url).headers(headers);
if let Some(body_str) = body {
request = request.body(body_str.to_string());
@ -335,11 +364,12 @@ impl Tool for HttpRequestTool {
let status = response.status();
let status_code = status.as_u16();
let response_text = response
.text()
.await
.map(|t| self.truncate_response(&t))
.unwrap_or_else(|_| "[Failed to read response body]".to_string());
// 流式限长读取:下载量在读取过程中即被约束,超限提前中止
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 output = format!(
"Status: {} {}\n\nResponse Body:\n{}",

View File

@ -55,11 +55,8 @@ 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 {
None
v.as_number().map(|n| n.to_string())
}
})
}

View File

@ -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,20 +24,13 @@ 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> {
@ -56,18 +49,11 @@ 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 副本
@ -80,9 +66,7 @@ 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
}
@ -97,9 +81,7 @@ 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
}
}

View File

@ -338,30 +338,28 @@ fn enrich_target_from_context(
_ => return target,
};
if !has_non_empty_string(&object, "channel") {
if let Some(channel_name) = context
if !has_non_empty_string(&object, "channel")
&& let Some(channel_name) = context
.channel_name
.as_ref()
.filter(|value| !value.trim().is_empty())
{
object.insert(
"channel".to_string(),
serde_json::Value::String(channel_name.clone()),
);
}
{
object.insert(
"channel".to_string(),
serde_json::Value::String(channel_name.clone()),
);
}
if !has_non_empty_string(&object, "chat_id") {
if let Some(chat_id) = context
if !has_non_empty_string(&object, "chat_id")
&& let Some(chat_id) = context
.chat_id
.as_ref()
.filter(|value| !value.trim().is_empty())
{
object.insert(
"chat_id".to_string(),
serde_json::Value::String(chat_id.clone()),
);
}
{
object.insert(
"chat_id".to_string(),
serde_json::Value::String(chat_id.clone()),
);
}
serde_json::Value::Object(object)

View File

@ -114,10 +114,11 @@ impl SchemaCleanr {
anyhow::bail!("Schema missing required 'type' field");
}
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");
}
if let Some(Value::String(t)) = obj.get("type")
&& t == "object"
&& !obj.contains_key("properties")
{
tracing::warn!("Object schema without 'properties' field may cause issues");
}
Ok(())
@ -173,10 +174,10 @@ impl SchemaCleanr {
}
// Handle anyOf/oneOf simplification
if obj.contains_key("anyOf") || obj.contains_key("oneOf") {
if let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack) {
return simplified;
}
if (obj.contains_key("anyOf") || obj.contains_key("oneOf"))
&& let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack)
{
return simplified;
}
// Build cleaned object
@ -244,13 +245,13 @@ impl SchemaCleanr {
return Self::preserve_meta(obj, Value::Object(Map::new()));
}
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);
}
if let Some(def_name) = Self::parse_local_ref(ref_value)
&& 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);
@ -342,15 +343,16 @@ impl SchemaCleanr {
if let Some(Value::Null) = obj.get("const") {
return true;
}
if let Some(Value::Array(arr)) = obj.get("enum") {
if arr.len() == 1 && matches!(arr[0], Value::Null) {
return true;
}
if let Some(Value::Array(arr)) = obj.get("enum")
&& arr.len() == 1
&& matches!(arr[0], Value::Null)
{
return true;
}
if let Some(Value::String(t)) = obj.get("type") {
if t == "null" {
return true;
}
if let Some(Value::String(t)) = obj.get("type")
&& t == "null"
{
return true;
}
}
false

View File

@ -19,6 +19,8 @@ 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;
@ -72,14 +74,20 @@ 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 {
stderr_clone.lock().await.push_str(&chunk);
let mut buf = stderr_clone.lock().await;
buf.push_str(&chunk);
cap_output_buffer(&mut buf);
} else {
stdout_clone.lock().await.push_str(&chunk);
let mut buf = stdout_clone.lock().await;
buf.push_str(&chunk);
cap_output_buffer(&mut buf);
}
}
});
@ -134,7 +142,9 @@ impl ShellSessionManager {
return Err("Session stdin is closed".to_string());
}
// Record output length before wait
// 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).
let prev_stdout_len = session.stdout_buf.lock().await.len();
let prev_stderr_len = session.stderr_buf.lock().await.len();
@ -154,11 +164,23 @@ impl ShellSessionManager {
}
}
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();
// 按字节偏移在锁内直接切片取新增输出:避免对整个缓冲做全量克隆,
// 也修复了旧实现"字节长度当字符数 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 mut result = String::new();
if !new_stdout.is_empty() {

View File

@ -211,10 +211,8 @@ impl Tool for SkillManageTool {
Err(err) => return Ok(error_result(&err)),
}
}
if reload {
if let Err(err) = self.skills.reload() {
return Ok(error_result(&err));
}
if reload && let Err(err) = self.skills.reload() {
return Ok(error_result(&err));
}
json!({

View File

@ -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,7 +535,11 @@ 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()
@ -877,10 +881,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
// 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。
// 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝,
// 防止 LLM 通过选择被禁子代理绕过限制。
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));
}
if let Some(cap) = &parent_context.parent_capability
&& let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name)
{
return Err(TaskError::InvalidArguments(msg));
}
// 3. 查找子代理定义
@ -1097,8 +1101,7 @@ 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;
}
@ -1140,11 +1143,7 @@ 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 下仍未完成的子代理列表
@ -1181,7 +1180,8 @@ 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,7 +1211,13 @@ 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 条目
@ -1236,7 +1242,9 @@ 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) => {
@ -1303,10 +1311,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
// 4.1 校验父智能体的子代理策略(白/黑名单)。
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
// 该子代理的专家resume 应失败(与 def 被删除即失败的安全语义一致)。
if let Some(cap) = &parent_context.parent_capability {
if let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) {
return Err(TaskError::InvalidArguments(msg));
}
if let Some(cap) = &parent_context.parent_capability
&& let Err(msg) = cap.check_subagent_allowed(&session.subagent_type)
{
return Err(TaskError::InvalidArguments(msg));
}
// 4.2 重新解析 def 以应用工具过滤。
@ -1406,10 +1414,11 @@ 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,
@ -1753,24 +1762,15 @@ 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,14 +1794,8 @@ 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()
@ -1811,30 +1805,17 @@ 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()
@ -1872,14 +1853,8 @@ 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()
@ -1942,13 +1917,7 @@ 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));
}
@ -1969,10 +1938,7 @@ impl SubagentRuntime {
// 更新内存中的 disable_state
{
let mut state = self
.disable_state
.write()
;
let mut state = self.disable_state.write();
match scope {
SubagentScope::User => {
if enabled {
@ -1992,10 +1958,7 @@ 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 {
@ -2023,10 +1986,7 @@ 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))?
@ -2089,10 +2049,7 @@ 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));
}
@ -2136,17 +2093,10 @@ 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))?;
@ -2201,11 +2151,7 @@ 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"))
@ -2632,7 +2578,7 @@ mod tests {
// 禁用后 prompt 不应包含 general无可用子代理时返回 None
let prompt = runtime.system_index_prompt_filtered();
assert!(prompt.map_or(true, |p| !p.contains("<name>general</name>")));
assert!(prompt.is_none_or(|p| !p.contains("<name>general</name>")));
}
#[test]
@ -3142,10 +3088,7 @@ 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]
@ -3288,7 +3231,8 @@ mod tests {
"directory should be preserved when it has other files"
);
assert!(
!temp.path()
!temp
.path()
.join(".picobot")
.join("subagents")
.join("mixed")

View File

@ -103,7 +103,7 @@ impl Tool for TaskTool {
// 2. 验证描述长度
let word_count = task_args.description.split_whitespace().count();
if task_args.description.len() > 50 || word_count > 7 || word_count < 1 {
if task_args.description.len() > 50 || !(1..=7).contains(&word_count) {
return Ok(ToolResult {
success: false,
output: String::new(),
@ -136,17 +136,17 @@ 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 {
if context.nesting_depth > max_depth {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Cannot create nested subagent: max nesting depth ({}) reached",
max_depth
)),
});
}
if let Some(max_depth) = self.max_nesting_depth
&& context.nesting_depth > max_depth
{
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Cannot create nested subagent: max nesting depth ({}) reached",
max_depth
)),
});
}
// 5. 执行任务

View File

@ -8,8 +8,10 @@ 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,
@ -23,12 +25,6 @@ pub enum TaskSessionState {
Unknown,
}
impl Default for TaskSessionState {
fn default() -> Self {
Self::Running
}
}
impl TaskSessionState {
pub fn as_str(&self) -> &'static str {
match self {

View File

@ -88,10 +88,10 @@ impl Tool for TodoReadTool {
// 2. 读锁查内存
{
let guard = self.state.read().await;
if let Some(items) = guard.get(&scope_key) {
if !items.is_empty() {
return Ok(success_result(items, &scope_key, "memory"));
}
if let Some(items) = guard.get(&scope_key)
&& !items.is_empty()
{
return Ok(success_result(items, &scope_key, "memory"));
}
}

View File

@ -1,5 +1,5 @@
use std::time::Duration;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::{mpsc, watch};
@ -58,11 +58,7 @@ 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)]

View File

@ -156,9 +156,7 @@ 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 {

View File

@ -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 = vec![
let messages = [
Message::system(
"You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate",
),

View File

@ -423,6 +423,16 @@ 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;
@ -1007,7 +1017,7 @@ function App() {
channels.find((c) => c.id === selectedChannel)?.name ??
'PicoBot')
}
onSendMessage={subAgentView || schedulerView ? () => {} : handleSendMessage}
onSendMessage={subAgentView || schedulerView ? noopSendMessage : handleSendMessage}
onNavigateToSubAgent={handleNavigateToSubAgent}
onStop={handleStopExecution}
showThinking={showThinking}
@ -1015,10 +1025,7 @@ function App() {
highlightedMessageId={highlightedMessageId}
sessionId={sessionId}
settingsClosedTick={settingsClosedTick}
onOpenSettings={() => {
setConfigInitialTab('experts');
setConfigPageOpen(true);
}}
onOpenSettings={openExpertsSettings}
/>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { ExpertSelector } from './ExpertSelector';
@ -55,6 +55,13 @@ 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
@ -67,9 +74,7 @@ export function ChatContainer({
sessionId={sessionId ?? null}
topicId={topicId ?? null}
settingsClosedTick={settingsClosedTick}
onSelectionChange={(effective) =>
setEffectiveModel({ provider: effective.provider, model: effective.model })
}
onSelectionChange={handleModelSelectionChange}
/>
</div>
);

View File

@ -132,6 +132,24 @@ 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);
@ -140,13 +158,7 @@ function AttachmentCard({ attachment }: { attachment: Attachment }) {
e.preventDefault();
const mimeType = attachment.mime_type || 'application/octet-stream';
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 blob = base64ToBlob(attachment.content_base64, mimeType);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@ -210,13 +222,7 @@ function ImageLightbox({
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation();
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 blob = base64ToBlob(src, mimeType);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;

View File

@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { MessageBubble } from './MessageBubble';
import type { ChatMessage } from '../../types/protocol';
@ -55,8 +55,13 @@ export function MessageList({
: undefined,
});
// 消息 id → virtualizer index 映射,用于 highlight 滚动定位
const messageIdToIndex = useRef<Map<string, number>>(new Map());
// 消息 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]);
// ---- scroll helpers ----
@ -167,7 +172,7 @@ export function MessageList({
useEffect(() => {
if (!highlightedMessageId) return;
const idx = messageIdToIndex.current.get(highlightedMessageId);
const idx = messageIdToIndex.get(highlightedMessageId);
if (idx === undefined) return;
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'smooth' });
@ -183,7 +188,7 @@ export function MessageList({
targetElement.classList.remove('todo-highlight');
}, 2000);
});
}, [highlightedMessageId, virtualizer]);
}, [highlightedMessageId, messageIdToIndex, virtualizer]);
// ---- 行高强制重测(修复 tanstack virtual-core 3.17.x 陈旧高度导致行重叠)----
// 3.17.x 在滚动状态会跳过同步测量、对缓冲区外的行跳过 RO 更新并复用缓存高度;
@ -241,10 +246,6 @@ 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();

View File

@ -24,7 +24,9 @@ export function getGatewaySettings(): GatewaySettings {
}
export function buildWsUrl(settings: GatewaySettings): string {
const base = `ws://${settings.host}:${settings.port}/ws`;
// HTTPS 页面下浏览器禁止混合内容ws://),需使用 wss://
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const base = `${protocol}://${settings.host}:${settings.port}/ws`;
// 远程访问时需要携带认证 token浏览器原生 WebSocket 不支持自定义 header
const token = getAuthToken();
return token ? `${base}?token=${encodeURIComponent(token)}` : base;

View File

@ -55,6 +55,99 @@ 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 只累加到 refrAF 批量合并为一次 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;
@ -70,33 +163,24 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
if (message.type === 'assistant_response') {
bumpTopicRefreshTrigger();
}
// stream_delta: accumulate into existing message by ID, or create new
// stream_delta: 累加到批处理缓冲区rAF 统一 flush不再逐 token setState
if (message.type === 'stream_delta') {
const msg = message as StreamDelta;
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;
}
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
return newStack;
});
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),
});
}
scheduleSubAgentDeltaFlush();
return;
}
// stream_end: no-op, assistant_response will replace
@ -168,85 +252,89 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
return newStack;
});
}
}, [bumpTopicRefreshTrigger]);
}, [bumpTopicRefreshTrigger, scheduleSubAgentDeltaFlush]);
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
setSubAgentStack((prev) => {
const idx = prev.findIndex((v) => v.taskId === taskId);
if (idx < 0) return prev;
const layer = prev[idx];
if (message.type === 'execution_completed') {
const newStack = [...prev];
newStack[idx] = { ...layer, status: 'completed' };
return newStack;
}
if (message.type === 'error') {
const errMsg = message as WsError;
const errorChatMsg: ChatMessage = {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
};
const newStack = [...prev];
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] };
return newStack;
}
const appendToSubAgentLayerMessage = useCallback(
(taskId: string, message: WsOutbound) => {
// stream_delta 在 setState 之外累加到批处理缓冲区updater 内不允许副作用)
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 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;
}
setSubAgentStack((prev) => {
const idx = prev.findIndex((v) => v.taskId === taskId);
if (idx < 0) return prev;
const layer = prev[idx];
if (message.type === 'execution_completed') {
const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated };
newStack[idx] = { ...layer, status: 'completed' };
return newStack;
}
if (message.type === 'error') {
const errMsg = message as WsError;
const errorChatMsg: ChatMessage = {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
};
const newStack = [...prev];
newStack[idx] = {
...layer,
status: 'error',
messages: [...layer.messages, errorChatMsg],
};
return newStack;
}
if (message.type === 'stream_end') return prev;
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
if (message.type === 'assistant_response') {
const existingIdx = layer.messages.findIndex(
(m) => m.id === chatMsg.id && m.type === 'message',
);
if (existingIdx >= 0) {
const updated = [...layer.messages];
updated[existingIdx] = chatMsg;
const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated };
return newStack;
}
} else if (
message.type === 'tool_call' ||
message.type === 'tool_result' ||
message.type === 'tool_pending'
) {
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;
}
if (message.type === 'stream_end') return prev;
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
if (message.type === 'assistant_response') {
const existingIdx = layer.messages.findIndex(
(m) => m.id === chatMsg.id && m.type === 'message',
);
if (existingIdx >= 0) {
const updated = [...layer.messages];
updated[existingIdx] = chatMsg;
const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated };
return newStack;
}
} else if (
message.type === 'tool_call' ||
message.type === 'tool_result' ||
message.type === 'tool_pending'
) {
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 => {
@ -267,6 +355,8 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
);
const exitSubAgentView = useCallback((): Command | null => {
// 栈变更前先落盘待处理 delta避免丢失或写入清空后的新栈
flushSubAgentDeltasSync();
const current = subAgentStackRef.current;
if (current.length <= 1) {
subAgentViewRef.current = null;
@ -282,26 +372,31 @@ 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 => {
const current = subAgentStackRef.current;
if (index < 0) {
subAgentViewRef.current = null;
subAgentStackRef.current = [];
setSubAgentStack([]);
return null;
}
if (index >= current.length) return null;
const newStack = current.slice(0, index + 1);
const newTop = newStack[newStack.length - 1];
subAgentViewRef.current = newTop;
const clearedStack = [...newStack];
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId };
}, []);
const navigateToSubAgentLevel = useCallback(
(index: number): Command | null => {
// 栈变更前先落盘待处理 delta避免丢失或写入清空后的新栈
flushSubAgentDeltasSync();
const current = subAgentStackRef.current;
if (index < 0) {
subAgentViewRef.current = null;
subAgentStackRef.current = [];
setSubAgentStack([]);
return null;
}
if (index >= current.length) return null;
const newStack = current.slice(0, index + 1);
const newTop = newStack[newStack.length - 1];
subAgentViewRef.current = newTop;
const clearedStack = [...newStack];
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId };
},
[flushSubAgentDeltasSync],
);
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
const handleSubAgentMessage = useCallback(
@ -309,6 +404,11 @@ 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) => {
@ -431,6 +531,7 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
appendToSubAgentLayerMessage,
sendCommand,
requestSubAgentTodoList,
flushSubAgentDeltasSync,
],
);