chore: 建立工程化基线(rustfmt + clippy + CI + eslint + prettier)

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

对抗性审查发现并修复:
- eslint 缺 caughtErrorsIgnorePattern 导致 catch(_) 误报为 error
- 前端 lint 未接入 CI,现已补上 Lint 步骤
- Makefile 与 CI 的 clippy flags 不一致,已对齐
This commit is contained in:
oudecheng 2026-08-03 23:24:02 +08:00
parent f37a5ffe6e
commit cda14360af
112 changed files with 4272 additions and 1439 deletions

83
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,83 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
env:
CARGO_TERM_COLOR: always
# 跳过前端构建build.rs 会触发 npm install + npm run build
# CI 中独立处理前端检查,避免 cargo check 时重复构建
SKIP_FRONTEND_BUILD: "1"
jobs:
rust-checks:
name: Rust fmt + clippy + test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Check formatting
run: cargo fmt --all -- --check
# 不使用 -D warnings现有代码有大量 warn 级别的存量问题
# unwrap/expect/clone 等),一上线会让 CI 一直红。
# 仅 correctness 类别clippy 默认 deny会失败先渐进收集。
# 等存量清理后再考虑加 -- -D warnings
- name: Clippy
run: cargo clippy --all-targets --all-features
- name: Build
run: cargo build --lib
- name: Run tests
run: cargo test --lib
frontend-checks:
name: Frontend build + test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install dependencies
working-directory: web
run: npm ci
- name: Lint (eslint)
working-directory: web
run: npm run lint
- name: Type check
working-directory: web
run: npx tsc --noEmit
- name: Run tests
working-directory: web
run: npm run test

View File

@ -3,6 +3,23 @@ name = "picobot"
version = "0.2.0" version = "0.2.0"
edition = "2024" edition = "2024"
[lints.rust]
# 编译期硬错误:避免明显的内存安全/正确性隐患
unsafe_op_in_unsafe_fn = "warn"
rust_2018_idioms = "warn"
[lints.clippy]
# 渐进式策略:
# - 不直接声明 lint groupcorrectness/suspicious/complexity/perf
# 因为 lint group 在 [lints] 中需用 priority 语法,简单 = "warn" 会报错;
# 且 clippy 默认已把 correctness 设为 deny无需重复声明。
# - 仅显式 warn 少量高价值且存量不大的具体规则,避免一上线淹没在噪音中。
# 后续随着存量问题清理,可逐步把 unwrap_used/expect_used 升级为 warn。
redundant_clone = "warn"
dbg_macro = "warn"
print_stderr = "warn"
print_stdout = "warn"
[dependencies] [dependencies]
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "multipart", "stream"] } reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "multipart", "stream"] }
dotenv = "0.15" dotenv = "0.15"

View File

@ -1,6 +1,6 @@
# PicoBot Web UI Makefile # PicoBot Web UI Makefile
.PHONY: dev dev-backend dev-frontend build clean install .PHONY: dev dev-backend dev-frontend build clean install check fmt fix help
# Default target # Default target
all: build all: build
@ -47,11 +47,22 @@ clean:
# Check code formatting and linting # Check code formatting and linting
check: check:
@echo "Checking frontend..." @echo "Checking formatting..."
cargo fmt --all -- --check
@echo "Checking frontend (lint + build)..."
cd web && npm run lint
cd web && npm run build cd web && npm run build
@echo "Checking Rust code..." @echo "Checking Rust code..."
cargo check cargo check
cargo clippy cargo clippy --all-targets --all-features
# Format all Rust code in place
fmt:
cargo fmt --all
# Auto-fix clippy lints where possible
fix:
cargo clippy --fix --all-targets --allow-dirty --allow-no-vcs
# Help # Help
help: help:
@ -66,4 +77,6 @@ help:
@echo " make run - Run production build" @echo " make run - Run production build"
@echo " make clean - Clean build artifacts" @echo " make clean - Clean build artifacts"
@echo " make check - Check code formatting and linting" @echo " make check - Check code formatting and linting"
@echo " make fmt - Format all Rust code in place"
@echo " make fix - Auto-fix clippy lints where possible"
@echo " make help - Show this help message" @echo " make help - Show this help message"

12
rustfmt.toml Normal file
View File

@ -0,0 +1,12 @@
# PicoBot Rust 代码格式化规则
#
# 设计原则:尽量贴近 rustfmt 默认风格,仅固化少数项目级偏好。
# 不追求激进重排,避免一次性产生大量 diff。
# 仅使用 stable rustfmt 支持的选项,不依赖 nightly 特性。
# 行宽100现代显示器友好
max_width = 100
# 缩进用 4 空格Rust 社区主流,与现有代码一致)
hard_tabs = false
tab_spaces = 4

View File

@ -2,12 +2,14 @@ use crate::agent::AgentRuntimeConfig;
use crate::agent::{SystemPromptContext, SystemPromptProvider}; use crate::agent::{SystemPromptContext, SystemPromptProvider};
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
use crate::bus::message::ToolMessageState; use crate::bus::message::ToolMessageState;
use crate::storage::ConversationRepository;
use crate::domain::messages::{ContentBlock, ToolCall}; use crate::domain::messages::{ContentBlock, ToolCall};
use crate::observability::{ use crate::observability::{
Observer, ObserverEvent, ToolExecutionOutcome, ToolExecutionState, truncate_args, Observer, ObserverEvent, ToolExecutionOutcome, ToolExecutionState, truncate_args,
}; };
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, StreamDelta, StreamCallback, create_provider}; use crate::providers::{
ChatCompletionRequest, LLMProvider, Message, StreamCallback, StreamDelta, create_provider,
};
use crate::storage::ConversationRepository;
use crate::text::{char_count, take_prefix_chars, take_suffix_chars}; use crate::text::{char_count, take_prefix_chars, take_suffix_chars};
use crate::tools::{ToolContext, ToolRegistry}; use crate::tools::{ToolContext, ToolRegistry};
use async_trait::async_trait; use async_trait::async_trait;
@ -254,7 +256,9 @@ fn filter_images_by_age_and_count(
} }
// 计算这条消息中的图片数量 // 计算这条消息中的图片数量
let image_count_in_msg = message.media_refs.iter() let image_count_in_msg = message
.media_refs
.iter()
.filter(|p| supported_image_mime_type(p).is_some()) .filter(|p| supported_image_mime_type(p).is_some())
.count(); .count();
@ -284,7 +288,9 @@ fn filter_images_by_age_and_count(
// 过滤图片:保留非图片媒体和指定数量的图片 // 过滤图片:保留非图片媒体和指定数量的图片
let mut images_kept_in_msg = 0usize; let mut images_kept_in_msg = 0usize;
let filtered_media_refs: Vec<String> = message.media_refs.iter() let filtered_media_refs: Vec<String> = message
.media_refs
.iter()
.filter_map(|path| { .filter_map(|path| {
if supported_image_mime_type(path).is_some() { if supported_image_mime_type(path).is_some() {
if images_kept_in_msg < keep_count { if images_kept_in_msg < keep_count {
@ -300,16 +306,22 @@ fn filter_images_by_age_and_count(
.collect(); .collect();
// 如果图片被过滤,添加文本提示 // 如果图片被过滤,添加文本提示
let original_image_count = message.media_refs.iter() let original_image_count = message
.media_refs
.iter()
.filter(|p| supported_image_mime_type(p).is_some()) .filter(|p| supported_image_mime_type(p).is_some())
.count(); .count();
let filtered_image_count = filtered_media_refs.iter() let filtered_image_count = filtered_media_refs
.iter()
.filter(|p| supported_image_mime_type(p).is_some()) .filter(|p| supported_image_mime_type(p).is_some())
.count(); .count();
let content = if original_image_count > filtered_image_count { let content = if original_image_count > filtered_image_count {
let notice = if exceeds_age_limit { let notice = if exceeds_age_limit {
format!("{} [图片已过期:超出 {} 条消息范围]", message.content, max_age_rounds) format!(
"{} [图片已过期:超出 {} 条消息范围]",
message.content, max_age_rounds
)
} else { } else {
format!("{} [图片已过期:超出最大图片数量限制]", message.content) format!("{} [图片已过期:超出最大图片数量限制]", message.content)
}; };
@ -705,7 +717,12 @@ impl<H: EmittedMessageHandler> PersistingEmittedMessageHandler<H> {
session_id: impl Into<String>, session_id: impl Into<String>,
topic_id: Option<String>, topic_id: Option<String>,
) -> Self { ) -> Self {
Self { inner, conversation_repository, session_id: session_id.into(), topic_id } Self {
inner,
conversation_repository,
session_id: session_id.into(),
topic_id,
}
} }
} }
@ -720,11 +737,9 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
let topic_id = self.topic_id.clone(); let topic_id = self.topic_id.clone();
let msg_for_persist = message.clone(); let msg_for_persist = message.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if let Err(e) = repo.append_message_with_topic( if let Err(e) =
&session_id, repo.append_message_with_topic(&session_id, topic_id.as_deref(), &msg_for_persist)
topic_id.as_deref(), {
&msg_for_persist,
) {
tracing::error!(error = %e, session_id = %session_id, tracing::error!(error = %e, session_id = %session_id,
"Failed to persist emitted message"); "Failed to persist emitted message");
} }
@ -741,11 +756,9 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
let topic_id = self.topic_id.clone(); let topic_id = self.topic_id.clone();
let msg_for_persist = message.clone(); let msg_for_persist = message.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if let Err(e) = repo.append_message_with_topic( if let Err(e) =
&session_id, repo.append_message_with_topic(&session_id, topic_id.as_deref(), &msg_for_persist)
topic_id.as_deref(), {
&msg_for_persist,
) {
tracing::error!(error = %e, session_id = %session_id, tracing::error!(error = %e, session_id = %session_id,
"Failed to persist emitted message"); "Failed to persist emitted message");
} }
@ -925,13 +938,15 @@ impl AgentLoop {
// Sanitize: remove any trailing incomplete tool call sequences // Sanitize: remove any trailing incomplete tool call sequences
// that may have been persisted before a process interruption. // that may have been persisted before a process interruption.
{ {
let tool_call_ids: Vec<_> = messages.iter() let tool_call_ids: Vec<_> = messages
.iter()
.filter(|m| m.role == "assistant") .filter(|m| m.role == "assistant")
.filter_map(|m| m.tool_calls.as_ref()) .filter_map(|m| m.tool_calls.as_ref())
.flatten() .flatten()
.map(|tc| tc.id.clone()) .map(|tc| tc.id.clone())
.collect(); .collect();
let tool_result_ids: Vec<_> = messages.iter() let tool_result_ids: Vec<_> = messages
.iter()
.filter(|m| m.role == "tool") .filter(|m| m.role == "tool")
.filter_map(|m| m.tool_call_id.clone()) .filter_map(|m| m.tool_call_id.clone())
.collect(); .collect();
@ -982,7 +997,8 @@ impl AgentLoop {
if self.check_cancelled().await { if self.check_cancelled().await {
tracing::info!(iteration, "Agent execution cancelled by user"); tracing::info!(iteration, "Agent execution cancelled by user");
let cancel = Self::build_cancel_result(iteration, emitted_messages); let cancel = Self::build_cancel_result(iteration, emitted_messages);
self.emit_live_tool_call_message(cancel.final_response.clone()).await; self.emit_live_tool_call_message(cancel.final_response.clone())
.await;
return Ok(cancel); return Ok(cancel);
} }
@ -1000,7 +1016,12 @@ impl AgentLoop {
); );
} }
let request = self.build_llm_request(&messages, system_prompt_context, tools.clone(), tools_tokens); let request = self.build_llm_request(
&messages,
system_prompt_context,
tools.clone(),
tools_tokens,
);
// Set up streaming delta consumer // Set up streaming delta consumer
// Pre-generate the message ID so stream deltas and the final assistant // Pre-generate the message ID so stream deltas and the final assistant
@ -1054,7 +1075,10 @@ impl AgentLoop {
drop(stream_callback); drop(stream_callback);
} else { } else {
// 无取消令牌stream_callback 被 move 进 chat_with_streaming调用完成即释放。 // 无取消令牌stream_callback 被 move 进 chat_with_streaming调用完成即释放。
llm_result = self.provider.chat_with_streaming(request, stream_callback).await; llm_result = self
.provider
.chat_with_streaming(request, stream_callback)
.await;
} }
// Close delta channel and wait for consumer to finish processing // Close delta channel and wait for consumer to finish processing
@ -1074,7 +1098,8 @@ impl AgentLoop {
let assistant_message = let assistant_message =
ChatMessage::assistant(recoverable_llm_message(&e.to_string())); ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
emitted_messages.push(assistant_message.clone()); emitted_messages.push(assistant_message.clone());
self.emit_live_tool_call_message(assistant_message.clone()).await; self.emit_live_tool_call_message(assistant_message.clone())
.await;
return Ok(AgentProcessResult { return Ok(AgentProcessResult {
final_response: assistant_message, final_response: assistant_message,
emitted_messages, emitted_messages,
@ -1104,9 +1129,14 @@ impl AgentLoop {
// If no tool calls, this is the final response // If no tool calls, this is the final response
if response.tool_calls.is_empty() { if response.tool_calls.is_empty() {
let result = self.build_final_response( let result = self
response, &streaming_message_id, had_streaming, &mut emitted_messages, .build_final_response(
).await; response,
&streaming_message_id,
had_streaming,
&mut emitted_messages,
)
.await;
return Ok(result); return Ok(result);
} }
@ -1177,9 +1207,13 @@ impl AgentLoop {
}; };
self.process_tool_results( self.process_tool_results(
&response.tool_calls, &tool_results, &mut loop_detector, &response.tool_calls,
&mut messages, &mut emitted_messages, &tool_results,
).await; &mut loop_detector,
&mut messages,
&mut emitted_messages,
)
.await;
// Loop continues to next iteration with updated messages // Loop continues to next iteration with updated messages
// PendingUserAction 工具的结果已在上方加入 messages // PendingUserAction 工具的结果已在上方加入 messages
@ -1193,7 +1227,9 @@ impl AgentLoop {
} }
// Max iterations reached - request final summary from LLM // Max iterations reached - request final summary from LLM
Ok(self.run_final_summary(&mut messages, system_prompt_context, &mut emitted_messages).await) Ok(self
.run_final_summary(&mut messages, system_prompt_context, &mut emitted_messages)
.await)
} }
/// 等待取消信号。若未配置 cancel_token永远不返回。 /// 等待取消信号。若未配置 cancel_token永远不返回。
@ -1255,11 +1291,8 @@ impl AgentLoop {
&filtered_messages, &filtered_messages,
system_prompt.as_ref().map(|p| p.content.as_str()), system_prompt.as_ref().map(|p| p.content.as_str()),
); );
let image_tokens = image_token_budget_for_request( let image_tokens =
&self.runtime_config, image_token_budget_for_request(&self.runtime_config, text_tokens, tools_tokens);
text_tokens,
tools_tokens,
);
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count); let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2); let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
@ -1299,7 +1332,8 @@ impl AgentLoop {
assistant_message.id = streaming_message_id.to_string(); assistant_message.id = streaming_message_id.to_string();
} }
emitted_messages.push(assistant_message.clone()); emitted_messages.push(assistant_message.clone());
self.emit_live_tool_call_message(assistant_message.clone()).await; self.emit_live_tool_call_message(assistant_message.clone())
.await;
AgentProcessResult { AgentProcessResult {
final_response: assistant_message, final_response: assistant_message,
emitted_messages: std::mem::take(emitted_messages), emitted_messages: std::mem::take(emitted_messages),
@ -1360,7 +1394,10 @@ impl AgentLoop {
// Defense: sanitize before final summary request // Defense: sanitize before final summary request
let removed = Self::sanitize_messages_for_llm(messages); let removed = Self::sanitize_messages_for_llm(messages);
if removed > 0 { if removed > 0 {
tracing::warn!(removed_count = removed, "Sanitized before max-iterations summary"); tracing::warn!(
removed_count = removed,
"Sanitized before max-iterations summary"
);
} }
// Add a message asking for summary // Add a message asking for summary
@ -1394,13 +1431,15 @@ impl AgentLoop {
match final_result { match final_result {
Ok(response) => { Ok(response) => {
let assistant_message = if let Some(reasoning_content) = response.reasoning_content { let assistant_message = if let Some(reasoning_content) = response.reasoning_content
{
ChatMessage::assistant_with_reasoning(response.content, reasoning_content) ChatMessage::assistant_with_reasoning(response.content, reasoning_content)
} else { } else {
ChatMessage::assistant(response.content) ChatMessage::assistant(response.content)
}; };
emitted_messages.push(assistant_message.clone()); emitted_messages.push(assistant_message.clone());
self.emit_live_tool_call_message(assistant_message.clone()).await; self.emit_live_tool_call_message(assistant_message.clone())
.await;
AgentProcessResult { AgentProcessResult {
final_response: assistant_message, final_response: assistant_message,
emitted_messages: std::mem::take(emitted_messages), emitted_messages: std::mem::take(emitted_messages),
@ -1416,7 +1455,8 @@ impl AgentLoop {
); );
let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string())); let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
emitted_messages.push(final_message.clone()); emitted_messages.push(final_message.clone());
self.emit_live_tool_call_message(final_message.clone()).await; self.emit_live_tool_call_message(final_message.clone())
.await;
AgentProcessResult { AgentProcessResult {
final_response: final_message, final_response: final_message,
emitted_messages: std::mem::take(emitted_messages), emitted_messages: std::mem::take(emitted_messages),
@ -1533,9 +1573,7 @@ impl AgentLoop {
// Log function call with name and arguments before execution // Log function call with name and arguments before execution
let args_str = match &tool_call.arguments { let args_str = match &tool_call.arguments {
serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(), serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(),
other => { other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string())
}
}; };
tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool"); tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool");
@ -1571,7 +1609,8 @@ impl AgentLoop {
Some(t) => t, Some(t) => t,
None => { None => {
tracing::warn!(tool = %tool_call.name, "Tool not found"); tracing::warn!(tool = %tool_call.name, "Tool not found");
let skill_hint = self.skills let skill_hint = self
.skills
.as_ref() .as_ref()
.and_then(|s| s.matching_skill_summary(&tool_call.name)); .and_then(|s| s.matching_skill_summary(&tool_call.name));
let error = match skill_hint { let error = match skill_hint {
@ -1581,10 +1620,7 @@ impl AgentLoop {
), ),
None => format!("Tool '{}' not found", tool_call.name), None => format!("Tool '{}' not found", tool_call.name),
}; };
return ToolExecutionOutcome::failure( return ToolExecutionOutcome::failure(format!("Error: {}", error), Some(error));
format!("Error: {}", error),
Some(error),
);
} }
}; };
@ -1977,10 +2013,7 @@ mod tests {
// 创建 3 条消息,每条都有图片 // 创建 3 条消息,每条都有图片
let messages: Vec<ChatMessage> = (0..3) let messages: Vec<ChatMessage> = (0..3)
.map(|i| { .map(|i| {
ChatMessage::user_with_media( ChatMessage::user_with_media(format!("message {}", i), vec![jpg_paths[i].clone()])
format!("message {}", i),
vec![jpg_paths[i].clone()],
)
}) })
.collect(); .collect();
@ -2148,7 +2181,8 @@ mod tests {
// Missing tool result for call_2 // Missing tool result for call_2
]; ];
let removed_count = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); let removed_count =
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
// Phase 1 removes the assistant message (call_2 has no result). // Phase 1 removes the assistant message (call_2 has no result).
// Phase 2 removes the orphaned tool result for call_1 (its parent // Phase 2 removes the orphaned tool result for call_1 (its parent
// assistant was removed). // assistant was removed).
@ -2181,9 +2215,7 @@ mod tests {
fn test_sanitize_removes_orphaned_tool_messages() { fn test_sanitize_removes_orphaned_tool_messages() {
// A lone tool message without a preceding assistant tool_calls // A lone tool message without a preceding assistant tool_calls
// is orphaned and should be removed. // is orphaned and should be removed.
let mut messages = vec![ let mut messages = vec![ChatMessage::tool("call_1", "calculator", "2")];
ChatMessage::tool("call_1", "calculator", "2"),
];
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 1); assert_eq!(removed, 1);
@ -2398,7 +2430,6 @@ mod tests {
ChatMessage::tool("t1_call", "read", "content A"), ChatMessage::tool("t1_call", "read", "content A"),
ChatMessage::assistant("task 1 is done"), ChatMessage::assistant("task 1 is done"),
// End of task 1 — complete sequence // End of task 1 — complete sequence
ChatMessage::user("task 2"), ChatMessage::user("task 2"),
ChatMessage::assistant_with_tool_calls( ChatMessage::assistant_with_tool_calls(
"doing task 2 — this got interrupted", "doing task 2 — this got interrupted",
@ -2417,7 +2448,6 @@ mod tests {
), ),
// Missing BOTH tool results — process was killed here // Missing BOTH tool results — process was killed here
// End of task 2 — orphaned sequence in the middle // End of task 2 — orphaned sequence in the middle
ChatMessage::user("task 3"), ChatMessage::user("task 3"),
ChatMessage::assistant_with_tool_calls( ChatMessage::assistant_with_tool_calls(
"doing task 3", "doing task 3",
@ -2526,11 +2556,19 @@ mod tests {
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
// The assistant should be removed (tool_calls stripped via removal) // The assistant should be removed (tool_calls stripped via removal)
// and the orphaned tool(A) should also be removed // and the orphaned tool(A) should also be removed
assert!(removed >= 2, "should remove both the assistant and orphaned tool message, got {}", removed); assert!(
removed >= 2,
"should remove both the assistant and orphaned tool message, got {}",
removed
);
assert_eq!(messages.len(), 1, "only the user message should remain"); assert_eq!(messages.len(), 1, "only the user message should remain");
assert_eq!(messages[0].role, "user"); assert_eq!(messages[0].role, "user");
assert!(messages.iter().all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())), assert!(
"no assistant should have tool_calls remaining"); messages
.iter()
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
"no assistant should have tool_calls remaining"
);
} }
#[test] #[test]
@ -2550,7 +2588,10 @@ mod tests {
]; ];
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages); let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
assert_eq!(removed, 0, "should not remove anything — tool result immediately follows"); assert_eq!(
removed, 0,
"should not remove anything — tool result immediately follows"
);
assert_eq!(messages.len(), 3); assert_eq!(messages.len(), 3);
} }
} }

View File

@ -1,3 +1,4 @@
use crate::agent::{AgentError, AgentRuntimeConfig};
use crate::bus::{ use crate::bus::{
ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION, ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION,
SYSTEM_CONTEXT_SCHEDULED_PROMPT, SYSTEM_CONTEXT_SCHEDULED_PROMPT,
@ -5,7 +6,6 @@ use crate::bus::{
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider}; use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider};
use crate::text::{char_count, take_prefix_chars}; use crate::text::{char_count, take_prefix_chars};
use crate::agent::{AgentError, AgentRuntimeConfig};
const TOKEN_ESTIMATE_SAFETY_MULTIPLIER: f64 = 1.2; const TOKEN_ESTIMATE_SAFETY_MULTIPLIER: f64 = 1.2;
const CJK_CHARS_PER_TOKEN: f64 = 2.0; const CJK_CHARS_PER_TOKEN: f64 = 2.0;
@ -50,9 +50,9 @@ impl HistoryUnit {
/// Estimate tokens for this unit alone. /// Estimate tokens for this unit alone.
fn estimate_tokens(&self) -> usize { fn estimate_tokens(&self) -> usize {
match self { match self {
HistoryUnit::SystemGuard(msg) | HistoryUnit::UserMessage(msg) | HistoryUnit::AssistantText(msg) => { HistoryUnit::SystemGuard(msg)
estimate_tokens(std::slice::from_ref(msg)) | HistoryUnit::UserMessage(msg)
} | HistoryUnit::AssistantText(msg) => estimate_tokens(std::slice::from_ref(msg)),
HistoryUnit::ToolRound { assistant, results } => { HistoryUnit::ToolRound { assistant, results } => {
let mut all = vec![assistant.clone()]; let mut all = vec![assistant.clone()];
all.extend(results.clone()); all.extend(results.clone());
@ -162,8 +162,8 @@ pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
} }
// Weighted token calculation: CJK chars need more tokens per character // Weighted token calculation: CJK chars need more tokens per character
let content_tokens = (cjk_count as f64 / CJK_CHARS_PER_TOKEN) let content_tokens =
+ (other_count as f64 / OTHER_CHARS_PER_TOKEN); (cjk_count as f64 / CJK_CHARS_PER_TOKEN) + (other_count as f64 / OTHER_CHARS_PER_TOKEN);
// JSON serialization overhead for message structure (fields, brackets, etc.) // JSON serialization overhead for message structure (fields, brackets, etc.)
let json_overhead = messages.len() * JSON_OVERHEAD_PER_MESSAGE; let json_overhead = messages.len() * JSON_OVERHEAD_PER_MESSAGE;
@ -1114,9 +1114,7 @@ mod tests {
#[test] #[test]
fn test_estimate_tokens_mixed_content() { fn test_estimate_tokens_mixed_content() {
let messages = vec![ let messages = vec![ChatMessage::user("Hello 世界 this is 测试")];
ChatMessage::user("Hello 世界 this is 测试"),
];
let tokens = estimate_tokens(&messages); let tokens = estimate_tokens(&messages);
// Content: 18 English chars + 4 CJK chars // Content: 18 English chars + 4 CJK chars
@ -1408,8 +1406,12 @@ mod tests {
// All units are AssistantText, split at 50% token ratio // All units are AssistantText, split at 50% token ratio
let split = compressor.find_safe_split_point(&units, 0.5); let split = compressor.find_safe_split_point(&units, 0.5);
// Should split somewhere in the middle (not 0, not len()) // Should split somewhere in the middle (not 0, not len())
assert!(split > 0 && split < units.len(), assert!(
"split {} should be between 0 and {}", split, units.len()); split > 0 && split < units.len(),
"split {} should be between 0 and {}",
split,
units.len()
);
} }
#[test] #[test]
@ -1458,10 +1460,14 @@ mod tests {
assert_eq!(compressed.len(), 3); assert_eq!(compressed.len(), 3);
// Critical invariant: NO tool_calls or tool_call_id anywhere // Critical invariant: NO tool_calls or tool_call_id anywhere
for msg in &compressed { for msg in &compressed {
assert!(msg.tool_calls.is_none(), assert!(
"compress_two_segment output should never contain tool_calls"); msg.tool_calls.is_none(),
assert!(msg.tool_call_id.is_none(), "compress_two_segment output should never contain tool_calls"
"compress_two_segment output should never contain tool_call_id"); );
assert!(
msg.tool_call_id.is_none(),
"compress_two_segment output should never contain tool_call_id"
);
} }
} }
} }

View File

@ -10,6 +10,6 @@ pub use agent_loop::{
pub use context_compressor::ContextCompressor; pub use context_compressor::ContextCompressor;
pub use runtime_config::AgentRuntimeConfig; pub use runtime_config::AgentRuntimeConfig;
pub use system_prompt::{ pub use system_prompt::{
CompositeSystemPromptProvider, generate_system_env_prompt, SystemPrompt, SystemPromptContext, CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider,
SystemPromptProvider, generate_system_env_prompt,
}; };

View File

@ -284,12 +284,13 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
} }
if msg.role == "assistant" if msg.role == "assistant"
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty()) && msg
.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
{ {
let tool_calls = msg.tool_calls.as_ref().unwrap(); let tool_calls = msg.tool_calls.as_ref().unwrap();
let all_have_results = tool_calls let all_have_results = tool_calls.iter().all(|tc| resolved_ids.contains(&tc.id));
.iter()
.all(|tc| resolved_ids.contains(&tc.id));
if all_have_results { if all_have_results {
for tc in tool_calls.iter() { for tc in tool_calls.iter() {
@ -359,12 +360,19 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
} }
if m.role == "assistant" if m.role == "assistant"
&& m.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty()) && m.tool_calls
.as_ref()
.map_or(false, |calls| !calls.is_empty())
{ {
let already_marked = remove_indices.contains(&i); let already_marked = remove_indices.contains(&i);
if !already_marked { if !already_marked {
pending_tool_ids = m.tool_calls.as_ref().unwrap() pending_tool_ids = m
.iter().map(|tc| tc.id.clone()).collect(); .tool_calls
.as_ref()
.unwrap()
.iter()
.map(|tc| tc.id.clone())
.collect();
pending_assistant_idx = Some(i); pending_assistant_idx = Some(i);
} }
} else if m.role == "tool" { } else if m.role == "tool" {
@ -511,7 +519,10 @@ pub enum OutboundEventKind {
impl OutboundMessage { impl OutboundMessage {
pub fn is_stream_delta(&self) -> bool { pub fn is_stream_delta(&self) -> bool {
matches!(self.event_kind, OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd) matches!(
self.event_kind,
OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd
)
} }
pub fn assistant( pub fn assistant(
@ -548,7 +559,8 @@ impl OutboundMessage {
reply_to: Option<String>, reply_to: Option<String>,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
) -> Self { ) -> Self {
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata); let mut message =
Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
message.event_kind = OutboundEventKind::SchedulerNotification; message.event_kind = OutboundEventKind::SchedulerNotification;
message message
} }
@ -561,7 +573,8 @@ impl OutboundMessage {
reply_to: Option<String>, reply_to: Option<String>,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
) -> Self { ) -> Self {
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata); let mut message =
Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
message.event_kind = OutboundEventKind::ErrorNotification; message.event_kind = OutboundEventKind::ErrorNotification;
message message
} }
@ -749,7 +762,8 @@ impl OutboundMessage {
"assistant" => { "assistant" => {
if let Some(tool_calls) = &message.tool_calls { if let Some(tool_calls) = &message.tool_calls {
let mut outbound = Vec::new(); let mut outbound = Vec::new();
let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some(); let has_content_or_reasoning =
!message.content.trim().is_empty() || message.reasoning_content.is_some();
if has_content_or_reasoning { if has_content_or_reasoning {
let mut resp = Self::assistant( let mut resp = Self::assistant(
channel.to_string(), channel.to_string(),
@ -766,7 +780,11 @@ impl OutboundMessage {
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复; // AssistantResponse 已携带 reasoning 时ToolCall 不再重复;
// 只有 AssistantResponse 没发时ToolCall 才带 reasoning // 只有 AssistantResponse 没发时ToolCall 才带 reasoning
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() }; let tc_reasoning = if has_content_or_reasoning {
None
} else {
message.reasoning_content.clone()
};
outbound.extend(tool_calls.iter().map(|tool_call| { outbound.extend(tool_calls.iter().map(|tool_call| {
let mut tc = Self::tool_call( let mut tc = Self::tool_call(
channel.to_string(), channel.to_string(),
@ -930,10 +948,7 @@ mod tests {
"calculator\nargs: {\"expression\":\"1 + 1\"}" "calculator\nargs: {\"expression\":\"1 + 1\"}"
); );
assert_eq!(outbound[1].tool_name.as_deref(), Some("read")); assert_eq!(outbound[1].tool_name.as_deref(), Some("read"));
assert_eq!( assert_eq!(outbound[1].content, "read\nargs: {\"path\":\"README.md\"}");
outbound[1].content,
"read\nargs: {\"path\":\"README.md\"}"
);
} }
#[test] #[test]

View File

@ -84,7 +84,10 @@ impl Channel for CliChannel {
self.shutdown_token.cancel(); self.shutdown_token.cancel();
let count = self.connections.read().await.len(); let count = self.connections.read().await.len();
self.connections.write().await.clear(); self.connections.write().await.clear();
tracing::info!(connection_count = count, "CliChannel stopped, all connections signaled to close"); tracing::info!(
connection_count = count,
"CliChannel stopped, all connections signaled to close"
);
Ok(()) Ok(())
} }

View File

@ -10,8 +10,8 @@ use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::{RwLock, broadcast}; use tokio::sync::{RwLock, broadcast};
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
use crate::bus::message::OutboundEventKind; use crate::bus::message::OutboundEventKind;
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
use crate::channels::base::{Channel, ChannelError}; use crate::channels::base::{Channel, ChannelError};
use crate::config::{FeishuChannelConfig, LLMProviderConfig}; use crate::config::{FeishuChannelConfig, LLMProviderConfig};
use crate::text::{char_count, truncate_with_ellipsis}; use crate::text::{char_count, truncate_with_ellipsis};
@ -548,9 +548,10 @@ impl FeishuChannel {
} }
let status = resp.status(); let status = resp.status();
let body = resp.text().await.map_err(|e| { let body = resp
ChannelError::Other(format!("Read upload image response error: {}", e)) .text()
})?; .await
.map_err(|e| ChannelError::Other(format!("Read upload image response error: {}", e)))?;
let result: UploadResp = serde_json::from_str(&body).map_err(|e| { let result: UploadResp = serde_json::from_str(&body).map_err(|e| {
ChannelError::Other(format!( ChannelError::Other(format!(
"Parse upload image response error: {} (status={}, body={})", "Parse upload image response error: {} (status={}, body={})",
@ -631,9 +632,10 @@ impl FeishuChannel {
} }
let status = resp.status(); let status = resp.status();
let body = resp.text().await.map_err(|e| { let body = resp
ChannelError::Other(format!("Read upload file response error: {}", e)) .text()
})?; .await
.map_err(|e| ChannelError::Other(format!("Read upload file response error: {}", e)))?;
let result: UploadResp = serde_json::from_str(&body).map_err(|e| { let result: UploadResp = serde_json::from_str(&body).map_err(|e| {
ChannelError::Other(format!( ChannelError::Other(format!(
"Parse upload file response error: {} (status={}, body={})", "Parse upload file response error: {} (status={}, body={})",
@ -982,9 +984,11 @@ impl FeishuChannel {
reply_to: Option<&str>, reply_to: Option<&str>,
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
if let Some(parent_id) = reply_to { if let Some(parent_id) = reply_to {
self.reply_to_feishu_message(parent_id, msg_type, content).await self.reply_to_feishu_message(parent_id, msg_type, content)
.await
} else { } else {
self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content).await self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content)
.await
} }
} }
@ -1440,7 +1444,8 @@ fn parse_post_content(content: &str) -> String {
} }
"code_block" => { "code_block" => {
let lang = el.get("language").and_then(|l| l.as_str()).unwrap_or(""); let lang = el.get("language").and_then(|l| l.as_str()).unwrap_or("");
let code_text = if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) { let code_text =
if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) {
content_arr content_arr
.iter() .iter()
.filter_map(|item| item.get("text").and_then(|t| t.as_str())) .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
@ -1448,7 +1453,10 @@ fn parse_post_content(content: &str) -> String {
.join("") .join("")
} else { } else {
// Fallback to text field for backwards compatibility // Fallback to text field for backwards compatibility
el.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string() el.get("text")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string()
}; };
out.push(format!("\n```{}\n{}\n```\n", lang, code_text)); out.push(format!("\n```{}\n{}\n```\n", lang, code_text));
} }
@ -2380,7 +2388,8 @@ mod tests {
#[test] #[test]
fn parse_post_content_handles_empty_code_block() { fn parse_post_content_handles_empty_code_block() {
// Test code_block with empty content // Test code_block with empty content
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#; let post_json =
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
let result = parse_post_content(post_json); let result = parse_post_content(post_json);
assert!(result.contains("```go")); assert!(result.contains("```go"));
} }
@ -2461,8 +2470,18 @@ impl Channel for FeishuChannel {
} }
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> { async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd | OutboundEventKind::ExecutionCompleted) if matches!(
|| msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false) msg.event_kind,
OutboundEventKind::ToolResult
| OutboundEventKind::ToolPending
| OutboundEventKind::StreamDelta
| OutboundEventKind::StreamEnd
| OutboundEventKind::ExecutionCompleted
) || msg
.metadata
.get("is_subagent_event")
.map(|v| v == "true")
.unwrap_or(false)
{ {
return Ok(()); return Ok(());
} }
@ -2553,7 +2572,13 @@ impl Channel for FeishuChannel {
} }
if !msg.content.trim().is_empty() { if !msg.content.trim().is_empty() {
self.dispatch_send(receive_id, receive_id_type, "text", msg.content.trim(), reply_to) self.dispatch_send(
receive_id,
receive_id_type,
"text",
msg.content.trim(),
reply_to,
)
.await?; .await?;
} }

View File

@ -48,7 +48,9 @@ impl ChannelManager {
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
for (name, channel_config) in &config.channels { for (name, channel_config) in &config.channels {
match channel_config { match channel_config {
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Feishu(feishu_config)) crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Feishu(
feishu_config,
))
| crate::config::ChannelConfig::LegacyFeishu(feishu_config) => { | crate::config::ChannelConfig::LegacyFeishu(feishu_config) => {
if feishu_config.enabled { if feishu_config.enabled {
let channel = FeishuChannel::new( let channel = FeishuChannel::new(
@ -72,7 +74,9 @@ impl ChannelManager {
tracing::info!(channel = %name, kind = channel_config.kind(), "Channel disabled in config"); tracing::info!(channel = %name, kind = channel_config.kind(), "Channel disabled in config");
} }
} }
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Wechat(wechat_config)) => { crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Wechat(
wechat_config,
)) => {
if wechat_config.enabled { if wechat_config.enabled {
let channel = WechatChannel::new( let channel = WechatChannel::new(
name.clone(), name.clone(),
@ -253,8 +257,14 @@ mod tests {
names.sort(); names.sort();
assert_eq!(names, vec!["backup", "primary", "websocket"]); assert_eq!(names, vec!["backup", "primary", "websocket"]);
assert_eq!(manager.get_channel("primary").await.unwrap().name(), "primary"); assert_eq!(
assert_eq!(manager.get_channel("backup").await.unwrap().name(), "backup"); manager.get_channel("primary").await.unwrap().name(),
"primary"
);
assert_eq!(
manager.get_channel("backup").await.unwrap().name(),
"backup"
);
} }
#[tokio::test] #[tokio::test]
@ -295,7 +305,8 @@ mod tests {
"cred_path": "<CRED_PATH>" "cred_path": "<CRED_PATH>"
} }
} }
}"#.replace("<CRED_PATH>", &cred_path_json), }"#
.replace("<CRED_PATH>", &cred_path_json),
) )
.unwrap(); .unwrap();
@ -314,6 +325,9 @@ mod tests {
names.sort(); names.sort();
assert_eq!(names, vec!["websocket", "wechat_main"]); assert_eq!(names, vec!["websocket", "wechat_main"]);
assert_eq!(manager.get_channel("wechat_main").await.unwrap().name(), "wechat_main"); assert_eq!(
manager.get_channel("wechat_main").await.unwrap().name(),
"wechat_main"
);
} }
} }

View File

@ -13,8 +13,8 @@ use tokio::sync::RwLock;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use wechatbot::{BotOptions, SendContent, WeChatBot}; use wechatbot::{BotOptions, SendContent, WeChatBot};
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
use crate::bus::message::OutboundEventKind; use crate::bus::message::OutboundEventKind;
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
use crate::channels::base::{Channel, ChannelError}; use crate::channels::base::{Channel, ChannelError};
use crate::config::{LLMProviderConfig, WechatChannelConfig}; use crate::config::{LLMProviderConfig, WechatChannelConfig};
@ -55,7 +55,10 @@ impl WechatChannel {
} }
fn sender_allowed(&self, sender_id: &str) -> bool { fn sender_allowed(&self, sender_id: &str) -> bool {
self.config.allow_from.iter().any(|pattern| pattern == "*" || pattern == sender_id) self.config
.allow_from
.iter()
.any(|pattern| pattern == "*" || pattern == sender_id)
} }
fn media_to_send_content( fn media_to_send_content(
@ -132,14 +135,17 @@ impl WechatChannel {
) -> Result<Vec<MediaItem>, ChannelError> { ) -> Result<Vec<MediaItem>, ChannelError> {
let Some(downloaded) = bot.download(&msg).await.map_err(|error| { let Some(downloaded) = bot.download(&msg).await.map_err(|error| {
ChannelError::Other(format!("WeChat media download failed: {}", error)) ChannelError::Other(format!("WeChat media download failed: {}", error))
})? else { })?
else {
return Ok(Vec::new()); return Ok(Vec::new());
}; };
let media_dir = Self::default_media_dir(); let media_dir = Self::default_media_dir();
tokio::fs::create_dir_all(&media_dir) tokio::fs::create_dir_all(&media_dir)
.await .await
.map_err(|error| ChannelError::Other(format!("Failed to create WeChat media dir: {}", error)))?; .map_err(|error| {
ChannelError::Other(format!("Failed to create WeChat media dir: {}", error))
})?;
let filename = Self::build_download_filename( let filename = Self::build_download_filename(
&downloaded.media_type, &downloaded.media_type,
@ -149,7 +155,9 @@ impl WechatChannel {
let file_path = media_dir.join(&filename); let file_path = media_dir.join(&filename);
tokio::fs::write(&file_path, downloaded.data) tokio::fs::write(&file_path, downloaded.data)
.await .await
.map_err(|error| ChannelError::Other(format!("Failed to write WeChat media file: {}", error)))?; .map_err(|error| {
ChannelError::Other(format!("Failed to write WeChat media file: {}", error))
})?;
tracing::info!(filename = %filename, media_type = %downloaded.media_type, "Downloaded WeChat media"); tracing::info!(filename = %filename, media_type = %downloaded.media_type, "Downloaded WeChat media");
@ -316,7 +324,11 @@ impl Channel for WechatChannel {
| OutboundEventKind::StreamDelta | OutboundEventKind::StreamDelta
| OutboundEventKind::StreamEnd | OutboundEventKind::StreamEnd
| OutboundEventKind::ExecutionCompleted | OutboundEventKind::ExecutionCompleted
) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false) ) || msg
.metadata
.get("is_subagent_event")
.map(|v| v == "true")
.unwrap_or(false)
{ {
return Ok(()); return Ok(());
} }
@ -344,7 +356,10 @@ impl Channel for WechatChannel {
None None
}; };
let content = Self::media_to_send_content(media, caption)?; let content = Self::media_to_send_content(media, caption)?;
self.bot.send_media(&msg.chat_id, content).await.map_err(|error| { self.bot
.send_media(&msg.chat_id, content)
.await
.map_err(|error| {
ChannelError::SendError(format!("WeChat media send failed: {}", error)) ChannelError::SendError(format!("WeChat media send failed: {}", error))
})?; })?;
tracing::info!( tracing::info!(
@ -409,13 +424,12 @@ mod tests {
std::fs::rename(file.path(), &doc_path).unwrap(); std::fs::rename(file.path(), &doc_path).unwrap();
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file"); let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
let content = WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap(); let content =
WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap();
match content { match content {
SendContent::File { SendContent::File {
file_name, file_name, caption, ..
caption,
..
} => { } => {
assert_eq!(file_name, doc_path.file_name().unwrap().to_string_lossy()); assert_eq!(file_name, doc_path.file_name().unwrap().to_string_lossy());
assert_eq!(caption.as_deref(), Some("note")); assert_eq!(caption.as_deref(), Some("note"));

View File

@ -117,7 +117,11 @@ impl InitWizard {
} }
let input = line.trim().to_string(); let input = line.trim().to_string();
Ok(if input.is_empty() { default.to_string() } else { input }) Ok(if input.is_empty() {
default.to_string()
} else {
input
})
} }
async fn prompt_required(&mut self, label: &str) -> Result<String, InitError> { async fn prompt_required(&mut self, label: &str) -> Result<String, InitError> {
@ -154,9 +158,9 @@ impl InitWizard {
let default_str = (default + 1).to_string(); let default_str = (default + 1).to_string();
let input = self.prompt_with_default(label, &default_str).await?; let input = self.prompt_with_default(label, &default_str).await?;
let selected: usize = input.parse().map_err(|_| { let selected: usize = input
InitError::InputError(format!("Invalid selection: {}", input)) .parse()
})?; .map_err(|_| InitError::InputError(format!("Invalid selection: {}", input)))?;
if selected == 0 || selected > options.len() { if selected == 0 || selected > options.len() {
return Err(InitError::InputError(format!( return Err(InitError::InputError(format!(
@ -195,9 +199,7 @@ impl InitWizard {
println!(" 4. Skip"); println!(" 4. Skip");
println!(); println!();
let choice = self let choice = self.prompt_with_default("Select option", "1").await?;
.prompt_with_default("Select option", "1")
.await?;
match choice.as_str() { match choice.as_str() {
"1" => return self.add_provider(existing).await, "1" => return self.add_provider(existing).await,
@ -243,9 +245,7 @@ impl InitWizard {
&mut self, &mut self,
existing: &Config, existing: &Config,
) -> Result<HashMap<String, ProviderConfig>, InitError> { ) -> Result<HashMap<String, ProviderConfig>, InitError> {
let provider_name = self let provider_name = self.prompt_with_default("Provider name", "default").await?;
.prompt_with_default("Provider name", "default")
.await?;
println!("Provider type:"); println!("Provider type:");
println!(" 1. openai"); println!(" 1. openai");
@ -307,7 +307,9 @@ impl InitWizard {
}; };
let type_options = vec!["openai".to_string(), "anthropic".to_string()]; let type_options = vec!["openai".to_string(), "anthropic".to_string()];
println!("Provider type:"); println!("Provider type:");
let type_idx = self.prompt_select("", &type_options, current_type_idx).await?; let type_idx = self
.prompt_select("", &type_options, current_type_idx)
.await?;
let provider_type = &type_options[type_idx]; let provider_type = &type_options[type_idx];
let base_url = self let base_url = self
@ -536,9 +538,7 @@ impl InitWizard {
providers: &HashMap<String, ProviderConfig>, providers: &HashMap<String, ProviderConfig>,
models: &HashMap<String, ModelConfig>, models: &HashMap<String, ModelConfig>,
) -> Result<HashMap<String, AgentConfig>, InitError> { ) -> Result<HashMap<String, AgentConfig>, InitError> {
let agent_name = self let agent_name = self.prompt_with_default("Agent name", "default").await?;
.prompt_with_default("Agent name", "default")
.await?;
// Select provider // Select provider
let provider_names: Vec<String> = providers.keys().cloned().collect(); let provider_names: Vec<String> = providers.keys().cloned().collect();
@ -600,7 +600,9 @@ impl InitWizard {
.position(|p| p == &current_agent.provider) .position(|p| p == &current_agent.provider)
.unwrap_or(0); .unwrap_or(0);
println!("Select provider:"); println!("Select provider:");
let provider_idx = self.prompt_select("", &provider_names, current_provider_idx).await?; let provider_idx = self
.prompt_select("", &provider_names, current_provider_idx)
.await?;
let selected_provider = &provider_names[provider_idx]; let selected_provider = &provider_names[provider_idx];
// Select new model // Select new model
@ -611,7 +613,9 @@ impl InitWizard {
.unwrap_or(0); .unwrap_or(0);
println!(); println!();
println!("Select model:"); println!("Select model:");
let model_idx = self.prompt_select("", &model_names, current_model_idx).await?; let model_idx = self
.prompt_select("", &model_names, current_model_idx)
.await?;
let selected_model = &model_names[model_idx]; let selected_model = &model_names[model_idx];
let agent = AgentConfig { let agent = AgentConfig {
@ -650,7 +654,11 @@ impl InitWizard {
if !existing.channels.is_empty() { if !existing.channels.is_empty() {
println!("Existing channels:"); println!("Existing channels:");
for (name, config) in &existing.channels { for (name, config) in &existing.channels {
let status = if config.enabled() { "enabled" } else { "disabled" }; let status = if config.enabled() {
"enabled"
} else {
"disabled"
};
println!(" - {} ({})", name, status); println!(" - {} ({})", name, status);
} }
println!(); println!();
@ -700,9 +708,7 @@ impl InitWizard {
println!("Configuring Feishu channel..."); println!("Configuring Feishu channel...");
println!(); println!();
let channel_name = self let channel_name = self.prompt_with_default("Channel name", "feishu").await?;
.prompt_with_default("Channel name", "feishu")
.await?;
let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu()); let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu());
@ -766,7 +772,8 @@ impl InitWizard {
println!(); println!();
println!("Starting WeChat login..."); println!("Starting WeChat login...");
self.do_wechat_login(base_url, &Self::default_wechat_cred_path()).await?; self.do_wechat_login(base_url, &Self::default_wechat_cred_path())
.await?;
println!(); println!();
println!("WeChat login successful! Credentials saved."); println!("WeChat login successful! Credentials saved.");
@ -796,9 +803,10 @@ impl InitWizard {
})), })),
}); });
let creds = bot.login(true).await.map_err(|e| { let creds = bot
InitError::WeChatError(format!("WeChat login failed: {}", e)) .login(true)
})?; .await
.map_err(|e| InitError::WeChatError(format!("WeChat login failed: {}", e)))?;
println!(); println!();
println!( println!(

View File

@ -1,7 +1,7 @@
pub mod channel; pub mod channel;
pub mod input;
pub mod init; pub mod init;
pub mod input;
pub use channel::CliChannel; pub use channel::CliChannel;
pub use input::{InputCommand, InputEvent, InputHandler};
pub use init::InitWizard; pub use init::InitWizard;
pub use input::{InputCommand, InputEvent, InputHandler};

View File

@ -26,8 +26,11 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut current_session_id: Option<String> = None; let mut current_session_id: Option<String> = None;
// Track message IDs that were already streamed so we can skip // Track message IDs that were already streamed so we can skip
// the duplicate AssistantResponse that arrives afterwards. // the duplicate AssistantResponse that arrives afterwards.
let mut streamed_message_ids: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut streamed_message_ids: std::collections::HashSet<String> =
input.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n").await?; std::collections::HashSet::new();
input
.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n")
.await?;
// Main loop: poll both stdin and WebSocket // Main loop: poll both stdin and WebSocket
loop { loop {

View File

@ -1,6 +1,6 @@
use crate::command::Command;
use crate::command::context::AdapterContext; use crate::command::context::AdapterContext;
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
/// 输入适配器:将渠道特定输入转换为 Command /// 输入适配器:将渠道特定输入转换为 Command
/// ///
@ -13,11 +13,7 @@ pub trait InputAdapter: Send + Sync {
/// - `Ok(Some(Command))`:成功解析为命令 /// - `Ok(Some(Command))`:成功解析为命令
/// - `Ok(None)`:不是命令(如普通聊天消息) /// - `Ok(None)`:不是命令(如普通聊天消息)
/// - `Err(CommandError)`:解析错误(如缺少参数) /// - `Err(CommandError)`:解析错误(如缺少参数)
fn try_parse( fn try_parse(&self, input: &str, ctx: AdapterContext) -> Result<Option<Command>, AdapterError>;
&self,
input: &str,
ctx: AdapterContext,
) -> Result<Option<Command>, AdapterError>;
} }
/// 输出适配器:将 CommandResponse 转换为渠道特定输出 /// 输出适配器:将 CommandResponse 转换为渠道特定输出

View File

@ -1,6 +1,6 @@
use crate::command::Command;
use crate::command::adapter::{AdapterError, InputAdapter}; use crate::command::adapter::{AdapterError, InputAdapter};
use crate::command::context::AdapterContext; use crate::command::context::AdapterContext;
use crate::command::Command;
/// Channel 输入适配器 /// Channel 输入适配器
/// ///

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter}; use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
use crate::command::context::AdapterContext; use crate::command::context::AdapterContext;
use crate::command::response::{CommandResponse, MessageKind}; use crate::command::response::{CommandResponse, MessageKind};
use crate::command::Command;
/// CLI 输入适配器 /// CLI 输入适配器
/// ///
@ -313,7 +313,14 @@ mod tests {
assert!(result.is_some()); assert!(result.is_some());
let cmd = result.unwrap(); let cmd = result.unwrap();
assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: false, .. })); assert!(matches!(
cmd,
Command::SaveSession {
filepath: None,
include_all: false,
..
}
));
} }
#[test] #[test]
@ -321,7 +328,9 @@ mod tests {
let adapter = CliInputAdapter::new(); let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test"); let ctx = AdapterContext::new("test");
let result = adapter.try_parse("/save-session ./debug/session.md", ctx).unwrap(); let result = adapter
.try_parse("/save-session ./debug/session.md", ctx)
.unwrap();
assert!(result.is_some()); assert!(result.is_some());
let cmd = result.unwrap(); let cmd = result.unwrap();
@ -344,7 +353,14 @@ mod tests {
assert!(result.is_some()); assert!(result.is_some());
let cmd = result.unwrap(); let cmd = result.unwrap();
assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: true, .. })); assert!(matches!(
cmd,
Command::SaveSession {
filepath: None,
include_all: true,
..
}
));
} }
#[test] #[test]
@ -352,7 +368,9 @@ mod tests {
let adapter = CliInputAdapter::new(); let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test"); let ctx = AdapterContext::new("test");
let result = adapter.try_parse("/save-session all ./debug/session.md", ctx).unwrap(); let result = adapter
.try_parse("/save-session all ./debug/session.md", ctx)
.unwrap();
assert!(result.is_some()); assert!(result.is_some());
let cmd = result.unwrap(); let cmd = result.unwrap();

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter}; use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
use crate::command::context::AdapterContext; use crate::command::context::AdapterContext;
use crate::command::response::{CommandResponse, MessageKind}; use crate::command::response::{CommandResponse, MessageKind};
use crate::command::Command;
use crate::protocol::WsOutbound; use crate::protocol::WsOutbound;
/// WebSocket 输入适配器 /// WebSocket 输入适配器
@ -79,8 +79,12 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(),
reasoning_content: None, user_message_id: None, subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
}, },
MessageKind::Notification => { MessageKind::Notification => {
// 根据元数据判断具体类型 // 根据元数据判断具体类型
@ -90,9 +94,13 @@ impl OutputAdapter for WebSocketOutputAdapter {
response.metadata.get("topic_id"), response.metadata.get("topic_id"),
response.metadata.get("title"), response.metadata.get("title"),
) { ) {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) { match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
topics_json,
) {
Ok(topics) => { Ok(topics) => {
let session_id = response.metadata.get("session_id") let session_id = response
.metadata
.get("session_id")
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
WsOutbound::TopicRenamed { WsOutbound::TopicRenamed {
@ -106,28 +114,37 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(),
reasoning_content: None, user_message_id: None, subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
}, },
} }
} else if let Some(topics_json) = response.metadata.get("topics") { } else if let Some(topics_json) = response.metadata.get("topics") {
// Topic 列表响应 - 优先检查 topics // Topic 列表响应 - 优先检查 topics
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) { match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
topics_json,
) {
Ok(topics) => { Ok(topics) => {
let session_id = response.metadata.get("session_id") let session_id = response
.metadata
.get("session_id")
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
WsOutbound::TopicList { WsOutbound::TopicList { topics, session_id }
topics,
session_id,
}
} }
Err(_) => WsOutbound::AssistantResponse { Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(),
reasoning_content: None, user_message_id: None, subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
}, },
} }
} else if let Some(session_id) = response.metadata.get("session_id") { } else if let Some(session_id) = response.metadata.get("session_id") {
@ -139,7 +156,9 @@ impl OutputAdapter for WebSocketOutputAdapter {
} }
} else { } else {
// 加载会话 // 加载会话
let message_count = response.metadata.get("message_count") let message_count = response
.metadata
.get("message_count")
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(0); .unwrap_or(0);
WsOutbound::SessionLoaded { WsOutbound::SessionLoaded {
@ -150,7 +169,9 @@ impl OutputAdapter for WebSocketOutputAdapter {
} }
} else if let Some(topic_id) = response.metadata.get("topic_id") { } else if let Some(topic_id) = response.metadata.get("topic_id") {
// 只有 topic_id可能是加载话题 // 只有 topic_id可能是加载话题
let message_count = response.metadata.get("message_count") let message_count = response
.metadata
.get("message_count")
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(0); .unwrap_or(0);
WsOutbound::SessionLoaded { WsOutbound::SessionLoaded {
@ -166,13 +187,19 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(),
reasoning_content: None, user_message_id: None, subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
}, },
} }
} else if let Some(sessions_json) = response.metadata.get("sessions") { } else if let Some(sessions_json) = response.metadata.get("sessions") {
// 会话列表响应 // 会话列表响应
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(sessions_json) { match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(
sessions_json,
) {
Ok(sessions) => { Ok(sessions) => {
let channel_name = response.metadata.get("channel_name").cloned(); let channel_name = response.metadata.get("channel_name").cloned();
WsOutbound::SessionList { WsOutbound::SessionList {
@ -185,28 +212,37 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(),
reasoning_content: None, user_message_id: None, subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
}, },
} }
} else if let Some(topics_json) = response.metadata.get("topics") { } else if let Some(topics_json) = response.metadata.get("topics") {
// Topic 列表响应 // Topic 列表响应
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) { match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
topics_json,
) {
Ok(topics) => { Ok(topics) => {
let session_id = response.metadata.get("session_id") let session_id = response
.metadata
.get("session_id")
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
WsOutbound::TopicList { WsOutbound::TopicList { topics, session_id }
topics,
session_id,
}
} }
Err(_) => WsOutbound::AssistantResponse { Err(_) => WsOutbound::AssistantResponse {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(),
reasoning_content: None, user_message_id: None, subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
}, },
} }
} else { } else {
@ -215,8 +251,12 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(),
reasoning_content: None, user_message_id: None, subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
} }
} }
} }
@ -230,8 +270,12 @@ impl OutputAdapter for WebSocketOutputAdapter {
id: response.request_id.to_string(), id: response.request_id.to_string(),
content: msg.content.clone(), content: msg.content.clone(),
role: "assistant".to_string(), role: "assistant".to_string(),
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()), attachments: Vec::new(),
reasoning_content: None, user_message_id: None, subagent_task_id: None,
topic_id: None,
timestamp: Some(crate::protocol::now_timestamp()),
reasoning_content: None,
user_message_id: None,
}, },
}; };
outbounds.push(outbound); outbounds.push(outbound);

View File

@ -1,8 +1,8 @@
use crate::agent::AgentError;
use crate::bus::InboundMessage; use crate::bus::InboundMessage;
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::agent::AgentError;
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;

View File

@ -1,8 +1,8 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicSummary; use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
@ -100,8 +100,7 @@ async fn handle_delete_topic(
}) })
.collect(); .collect();
let topics_json = let topics_json = serde_json::to_string(&topic_summaries)
serde_json::to_string(&topic_summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
let message = format!("✓ 已删除话题: {}", topic_title); let message = format!("✓ 已删除话题: {}", topic_title);
@ -130,9 +129,7 @@ mod tests {
// 先创建 session 和 topic // 先创建 session 和 topic
let session = store.create_session("test_channel", Some("test")).unwrap(); let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store let topic = store.create_topic(&session.id, "test topic", None).unwrap();
.create_topic(&session.id, "test topic", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel") let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id) .with_session_id(&session.id)

View File

@ -1,9 +1,9 @@
use crate::agent::context_compressor::estimate_tokens; use crate::agent::context_compressor::estimate_tokens;
use crate::agent::{SystemPromptContext, SystemPromptProvider}; use crate::agent::{SystemPromptContext, SystemPromptProvider};
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
@ -58,17 +58,23 @@ async fn handle_get_current_session(
handler: &GetCurrentSessionCommandHandler, handler: &GetCurrentSessionCommandHandler,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
let topic_id = ctx.topic_id.as_deref() let topic_id = ctx
.topic_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_CURRENT_TOPIC", "No current topic"))?; .ok_or_else(|| CommandError::new("NO_CURRENT_TOPIC", "No current topic"))?;
let chat_id = ctx.chat_id.as_deref() let chat_id = ctx
.chat_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat id".to_string()))?; .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat id".to_string()))?;
let topic = handler let topic = handler
.store .store
.get_topic(topic_id) .get_topic(topic_id)
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))? .map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; .ok_or_else(|| {
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
// 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史) // 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史)
let messages = handler let messages = handler
@ -88,7 +94,8 @@ async fn handle_get_current_session(
user_message_count, user_message_count,
}; };
provider.build(&system_prompt_context) provider
.build(&system_prompt_context)
.map(|sp| { .map(|sp| {
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
let system_msg = ChatMessage::system(&sp.content); let system_msg = ChatMessage::system(&sp.content);

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -44,8 +44,7 @@ impl CommandHandler for HelpCommandHandler {
let metadata = self.metadata.lock().unwrap(); let metadata = self.metadata.lock().unwrap();
let help_text = format_help(&metadata); let help_text = format_help(&metadata);
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id).with_message(MessageKind::Text, &help_text))
.with_message(MessageKind::Text, &help_text))
} }
} }

View File

@ -1,8 +1,8 @@
use crate::channels::manager::ChannelManager; use crate::channels::manager::ChannelManager;
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -68,7 +68,6 @@ impl CommandHandler for ListMemoriesCommandHandler {
let memories_json = serde_json::to_string(&summaries) let memories_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id).with_metadata("memories", &memories_json))
.with_metadata("memories", &memories_json))
} }
} }

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::protocol::{SchedulerJobSessionLookup, SchedulerJobSummary}; use crate::protocol::{SchedulerJobSessionLookup, SchedulerJobSummary};
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
@ -67,8 +67,7 @@ impl CommandHandler for ListSchedulerJobsCommandHandler {
let jobs_json = serde_json::to_string(&summaries) let jobs_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id).with_metadata("scheduler_jobs", &jobs_json))
.with_metadata("scheduler_jobs", &jobs_json))
} }
} }

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
@ -50,7 +50,9 @@ async fn handle_list_sessions(
_include_archived: bool, _include_archived: bool,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
let session_id = ctx.session_id.as_deref() let session_id = ctx
.session_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
let topics = handler let topics = handler
@ -72,7 +74,8 @@ async fn handle_list_sessions(
let marker = if is_current { " *" } else { "" }; let marker = if is_current { " *" } else { "" };
// 使用辅助方法获取消息数量 // 使用辅助方法获取消息数量
let msg_count = handler.store let msg_count = handler
.store
.get_topic_message_count(&topic.id) .get_topic_message_count(&topic.id)
.unwrap_or(0); .unwrap_or(0);

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::protocol::SessionSummary; use crate::protocol::SessionSummary;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -58,7 +58,6 @@ impl CommandHandler for ListSkillsCommandHandler {
let skills_json = serde_json::to_string(&summaries) let skills_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id).with_metadata("skills", &skills_json))
.with_metadata("skills", &skills_json))
} }
} }

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::protocol::TodoItemSummary; use crate::protocol::TodoItemSummary;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -50,9 +50,7 @@ impl CommandHandler for ListTopicsCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::ListTopics { session_id } => { Command::ListTopics { session_id } => handle_list_topics(self, session_id, ctx).await,
handle_list_topics(self, session_id, ctx).await
}
_ => unreachable!(), _ => unreachable!(),
} }
} }

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use async_trait::async_trait; use async_trait::async_trait;
/// 加载指定 channel + chat_id 的对话消息。 /// 加载指定 channel + chat_id 的对话消息。

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::tools::task::types::{TaskSession, TaskSessionState}; use crate::tools::task::types::{TaskSession, TaskSessionState};
@ -14,11 +14,11 @@ pub struct LoadTaskMessagesCommandHandler {
} }
impl LoadTaskMessagesCommandHandler { impl LoadTaskMessagesCommandHandler {
pub fn new( pub fn new(task_repository: Arc<dyn TaskRepository>, store: Arc<SessionStore>) -> Self {
task_repository: Arc<dyn TaskRepository>, Self {
store: Arc<SessionStore>, task_repository,
) -> Self { store,
Self { task_repository, store } }
} }
} }
@ -62,11 +62,7 @@ async fn handle_load_task_messages(
); );
// 1. Try in-memory repository first // 1. Try in-memory repository first
let task = match handler let task = match handler.task_repository.load_task_session(&task_id).await {
.task_repository
.load_task_session(&task_id)
.await
{
Ok(Some(task)) => { Ok(Some(task)) => {
tracing::info!( tracing::info!(
task_id = %task.id, task_id = %task.id,
@ -186,6 +182,9 @@ fn parse_subagent_title(title: &str) -> (String, String) {
return (agent_type, desc); return (agent_type, desc);
} }
} }
let desc = title.strip_prefix("Subagent: ").unwrap_or(title).to_string(); let desc = title
.strip_prefix("Subagent: ")
.unwrap_or(title)
.to_string();
("general".to_string(), desc) ("general".to_string(), desc)
} }

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
@ -37,9 +37,7 @@ impl CommandHandler for LoadTopicCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::LoadTopic { topic_id } => { Command::LoadTopic { topic_id } => handle_load_topic(self, topic_id, ctx).await,
handle_load_topic(self, topic_id, ctx).await
}
_ => unreachable!(), _ => unreachable!(),
} }
} }
@ -54,7 +52,9 @@ async fn handle_load_topic(
.store .store
.get_topic(&topic_id) .get_topic(&topic_id)
.map_err(|e| CommandError::new("LOAD_TOPIC_ERROR", e.to_string()))? .map_err(|e| CommandError::new("LOAD_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; .ok_or_else(|| {
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &topic.title) .with_message(MessageKind::Notification, &topic.title)

View File

@ -1,8 +1,8 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse}; use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command; use crate::storage::{GLOBAL_SCOPE_KEY, MemoryUpsert, SessionStore};
use crate::storage::{MemoryUpsert, SessionStore, GLOBAL_SCOPE_KEY};
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
@ -17,10 +17,7 @@ impl MemoryCrudCommandHandler {
} }
/// 通过 ID 查找记忆的 namespace 和 memory_key /// 通过 ID 查找记忆的 namespace 和 memory_key
fn find_by_id( fn find_by_id(store: &SessionStore, id: &str) -> Result<Option<(String, String)>, CommandError> {
store: &SessionStore,
id: &str,
) -> Result<Option<(String, String)>, CommandError> {
let records = store let records = store
.list_memories_for_scope("user", GLOBAL_SCOPE_KEY) .list_memories_for_scope("user", GLOBAL_SCOPE_KEY)
.map_err(|e| CommandError::new("LIST_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("LIST_ERROR", e.to_string()))?;
@ -35,7 +32,9 @@ impl CommandHandler for MemoryCrudCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool { fn can_handle(&self, cmd: &Command) -> bool {
matches!( matches!(
cmd, cmd,
Command::CreateMemory { .. } | Command::UpdateMemory { .. } | Command::DeleteMemory { .. } Command::CreateMemory { .. }
| Command::UpdateMemory { .. }
| Command::DeleteMemory { .. }
) )
} }
@ -112,7 +111,6 @@ impl CommandHandler for MemoryCrudCommandHandler {
_ => unreachable!(), _ => unreachable!(),
} }
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id).with_metadata("memory_updated", "true"))
.with_metadata("memory_updated", "true"))
} }
} }

View File

@ -4,15 +4,15 @@ pub mod help;
pub mod list_channels; pub mod list_channels;
pub mod list_memories; pub mod list_memories;
pub mod list_scheduler_jobs; pub mod list_scheduler_jobs;
pub mod list_skills;
pub mod list_todos;
pub mod memory_crud;
pub mod list_sessions; pub mod list_sessions;
pub mod list_sessions_by_channel; pub mod list_sessions_by_channel;
pub mod list_skills;
pub mod list_todos;
pub mod list_topics; pub mod list_topics;
pub mod load_chat_messages; pub mod load_chat_messages;
pub mod load_task_messages; pub mod load_task_messages;
pub mod load_topic; pub mod load_topic;
pub mod memory_crud;
pub mod rename_topic; pub mod rename_topic;
pub mod save_session; pub mod save_session;
pub mod save_topic; pub mod save_topic;
@ -22,8 +22,7 @@ pub mod switch_topic;
// 导出公共函数供其他模块复用 // 导出公共函数供其他模块复用
pub use save_session::{ pub use save_session::{
escape_yaml_string, format_message_content, format_timestamp, SubagentTaskData, escape_yaml_string, format_message_content, format_timestamp,
generate_messages_markdown, generate_system_prompt_markdown, generate_messages_markdown, generate_subagent_tasks_markdown, generate_system_prompt_markdown,
generate_subagent_tasks_markdown, load_subagent_data, SubagentTaskData, load_subagent_data,
}; };

View File

@ -1,8 +1,8 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicSummary; use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::Arc; use std::sync::Arc;
@ -86,7 +86,10 @@ async fn handle_rename_topic(
let topic_summaries = serialize_summaries(&topics); let topic_summaries = serialize_summaries(&topics);
return Ok(CommandResponse::success(ctx.request_id) return Ok(CommandResponse::success(ctx.request_id)
.with_message(MessageKind::Notification, &format!("✓ 话题标题未变化: {}", trimmed_title)) .with_message(
MessageKind::Notification,
&format!("✓ 话题标题未变化: {}", trimmed_title),
)
.with_metadata("topics", &topic_summaries) .with_metadata("topics", &topic_summaries)
.with_metadata("topic_id", &topic_id) .with_metadata("topic_id", &topic_id)
.with_metadata("title", trimmed_title) .with_metadata("title", trimmed_title)
@ -149,9 +152,7 @@ mod tests {
let store = handler.store.clone(); let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap(); let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store let topic = store.create_topic(&session.id, "old title", None).unwrap();
.create_topic(&session.id, "old title", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel") let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id) .with_session_id(&session.id)
@ -166,8 +167,14 @@ mod tests {
let resp = result.unwrap(); let resp = result.unwrap();
assert!(resp.success); assert!(resp.success);
assert_eq!(resp.metadata.get("title").map(String::as_str), Some("new title")); assert_eq!(
assert_eq!(resp.metadata.get("topic_id").map(String::as_str), Some(topic.id.as_str())); resp.metadata.get("title").map(String::as_str),
Some("new title")
);
assert_eq!(
resp.metadata.get("topic_id").map(String::as_str),
Some(topic.id.as_str())
);
assert!(resp.metadata.contains_key("topics")); assert!(resp.metadata.contains_key("topics"));
// 验证存储层已更新 // 验证存储层已更新
@ -181,9 +188,7 @@ mod tests {
let store = handler.store.clone(); let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap(); let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store let topic = store.create_topic(&session.id, "old title", None).unwrap();
.create_topic(&session.id, "old title", None)
.unwrap();
let ctx = CommandContext::new("test", "test_channel") let ctx = CommandContext::new("test", "test_channel")
.with_session_id(&session.id) .with_session_id(&session.id)
@ -229,9 +234,7 @@ mod tests {
let store = handler.store.clone(); let store = handler.store.clone();
let session = store.create_session("test_channel", Some("test")).unwrap(); let session = store.create_session("test_channel", Some("test")).unwrap();
let topic = store let topic = store.create_topic(&session.id, "same title", None).unwrap();
.create_topic(&session.id, "same title", None)
.unwrap();
let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at; let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at;
// 等待一秒确保 updated_at 会变化(如果真的写入) // 等待一秒确保 updated_at 会变化(如果真的写入)

View File

@ -1,12 +1,12 @@
use crate::agent::AgentError;
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::bus::InboundMessage; use crate::bus::InboundMessage;
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata, InChatCommandHandler}; use crate::command::handler::{CommandHandler, CommandMetadata, InChatCommandHandler};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::{SessionRecord, SessionStore}; use crate::storage::{SessionRecord, SessionStore};
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::agent::AgentError;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{Local, TimeZone}; use chrono::{Local, TimeZone};
use std::path::PathBuf; use std::path::PathBuf;
@ -65,7 +65,8 @@ pub async fn save_session_to_file(
let system_prompt = build_system_prompt(system_prompt_provider, &record, user_message_count); let system_prompt = build_system_prompt(system_prompt_provider, &record, user_message_count);
// 生成 Markdown 内容 // 生成 Markdown 内容
let markdown = generate_markdown_with_subagents(&record, &system_prompt, &messages, &subagent_data); let markdown =
generate_markdown_with_subagents(&record, &system_prompt, &messages, &subagent_data);
// 确定输出路径 // 确定输出路径
let output_path = resolve_filepath(filepath, &record); let output_path = resolve_filepath(filepath, &record);
@ -79,8 +80,7 @@ pub async fn save_session_to_file(
} }
// 写入文件 // 写入文件
std::fs::write(&output_path, markdown) std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(output_path) Ok(output_path)
} }
@ -134,9 +134,11 @@ impl CommandHandler for SaveSessionCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::SaveSession { filepath, include_all, include_subagents } => { Command::SaveSession {
handle_save_session(self, filepath, include_all, include_subagents, ctx).await filepath,
} include_all,
include_subagents,
} => handle_save_session(self, filepath, include_all, include_subagents, ctx).await,
_ => unreachable!(), _ => unreachable!(),
} }
} }
@ -199,13 +201,9 @@ async fn handle_save_session(
// 根据 include_all 获取消息数量 // 根据 include_all 获取消息数量
let message_count = if include_all { let message_count = if include_all {
handler handler.store.load_all_messages(session_id)
.store
.load_all_messages(session_id)
} else { } else {
handler handler.store.load_messages(session_id)
.store
.load_messages(session_id)
} }
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))? .map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?
.len(); .len();
@ -215,9 +213,15 @@ async fn handle_save_session(
MessageKind::Notification, MessageKind::Notification,
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
// 统一转换为正斜杠以保证显示完整(跨平台兼容) // 统一转换为正斜杠以保证显示完整(跨平台兼容)
&format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/")), &format!(
"Session saved to: {}",
output_path.display().to_string().replace('\\', "/")
),
)
.with_metadata(
"filepath",
&output_path.display().to_string().replace('\\', "/"),
) )
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
.with_metadata("message_count", &message_count.to_string())) .with_metadata("message_count", &message_count.to_string()))
} }
@ -347,12 +351,18 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
output.push_str("# Subagent Tasks\n\n"); output.push_str("# Subagent Tasks\n\n");
for task in subagent_data { for task in subagent_data {
output.push_str(&format!("## Task: {} ({})", task.description, task.subagent_type)); output.push_str(&format!(
"## Task: {} ({})",
task.description, task.subagent_type
));
output.push('\n'); output.push('\n');
output.push_str(&format!("**Task ID:** `{}`\n\n", task.task_id)); output.push_str(&format!("**Task ID:** `{}`\n\n", task.task_id));
output.push_str(&format!("**Session ID:** `{}`\n\n", task.session_id)); output.push_str(&format!("**Session ID:** `{}`\n\n", task.session_id));
output.push_str(&format!("**Status:** {}\n\n", task.state)); output.push_str(&format!("**Status:** {}\n\n", task.state));
output.push_str(&format!("**Created:** {}\n\n", format_timestamp(task.created_at))); output.push_str(&format!(
"**Created:** {}\n\n",
format_timestamp(task.created_at)
));
output.push_str(&format!("**Message Count:** {}\n\n", task.messages.len())); output.push_str(&format!("**Message Count:** {}\n\n", task.messages.len()));
// 子智能体消息 // 子智能体消息
@ -361,7 +371,10 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
for (idx, msg) in task.messages.iter().enumerate() { for (idx, msg) in task.messages.iter().enumerate() {
output.push_str(&format!("#### Message {}\n\n", idx + 1)); output.push_str(&format!("#### Message {}\n\n", idx + 1));
output.push_str(&format!("**Role:** {}\n\n", msg.role)); output.push_str(&format!("**Role:** {}\n\n", msg.role));
output.push_str(&format!("**Time:** {}\n\n", format_timestamp(msg.timestamp))); output.push_str(&format!(
"**Time:** {}\n\n",
format_timestamp(msg.timestamp)
));
if let Some(ref reasoning) = msg.reasoning_content { if let Some(ref reasoning) = msg.reasoning_content {
output.push_str("**Reasoning:**\n"); output.push_str("**Reasoning:**\n");
@ -676,7 +689,12 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
inbound: &InboundMessage, inbound: &InboundMessage,
session_manager: &crate::gateway::session::SessionManager, session_manager: &crate::gateway::session::SessionManager,
) -> Result<Option<String>, AgentError> { ) -> Result<Option<String>, AgentError> {
let Command::SaveSession { filepath, include_all, include_subagents } = cmd else { let Command::SaveSession {
filepath,
include_all,
include_subagents,
} = cmd
else {
return Ok(None); return Ok(None);
}; };
@ -707,7 +725,10 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
// 返回成功或失败消息 // 返回成功或失败消息
match result { match result {
Ok(output_path) => { Ok(output_path) => {
let msg = format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/")); let msg = format!(
"Session saved to: {}",
output_path.display().to_string().replace('\\', "/")
);
tracing::info!("{}", msg); tracing::info!("{}", msg);
Ok(Some(msg)) Ok(Some(msg))
} }
@ -774,7 +795,10 @@ mod tests {
fn test_escape_yaml_string() { fn test_escape_yaml_string() {
assert_eq!(escape_yaml_string("simple"), "simple"); assert_eq!(escape_yaml_string("simple"), "simple");
assert_eq!(escape_yaml_string("with: colon"), "\"with: colon\""); assert_eq!(escape_yaml_string("with: colon"), "\"with: colon\"");
assert_eq!(escape_yaml_string("with \"quote\""), "\"with \\\"quote\\\"\""); assert_eq!(
escape_yaml_string("with \"quote\""),
"\"with \\\"quote\\\"\""
);
} }
#[test] #[test]
@ -835,14 +859,26 @@ mod tests {
#[test] #[test]
fn test_can_handle() { fn test_can_handle() {
let store = Arc::new(SessionStore::in_memory().unwrap()); let store = Arc::new(SessionStore::in_memory().unwrap());
let task_repository = Arc::new(crate::tools::task::repository::InMemoryTaskRepository::new()); let task_repository =
Arc::new(crate::tools::task::repository::InMemoryTaskRepository::new());
let provider = Arc::new(TestSystemPromptProvider); let provider = Arc::new(TestSystemPromptProvider);
let handler = SaveSessionCommandHandler::new(store, task_repository, provider); let handler = SaveSessionCommandHandler::new(store, task_repository, provider);
assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: false, include_subagents: false })); assert!(handler.can_handle(&Command::SaveSession {
assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: true, include_subagents: false })); filepath: None,
include_all: false,
include_subagents: false
}));
assert!(handler.can_handle(&Command::SaveSession {
filepath: None,
include_all: true,
include_subagents: false
}));
assert!(!handler.can_handle(&Command::CreateSession { title: None })); assert!(!handler.can_handle(&Command::CreateSession { title: None }));
assert!(!handler.can_handle(&Command::SaveTopic { filepath: None, include_subagents: false })); assert!(!handler.can_handle(&Command::SaveTopic {
filepath: None,
include_subagents: false
}));
} }
/// 测试用的系统提示词提供者 /// 测试用的系统提示词提供者

View File

@ -1,14 +1,13 @@
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::{ use crate::command::handlers::{
escape_yaml_string, format_timestamp, generate_messages_markdown, SubagentTaskData, escape_yaml_string, format_timestamp, generate_messages_markdown,
generate_subagent_tasks_markdown, generate_system_prompt_markdown, generate_subagent_tasks_markdown, generate_system_prompt_markdown, load_subagent_data,
load_subagent_data, SubagentTaskData,
}; };
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::{SessionStore, TopicRecord}; use crate::storage::{SessionStore, TopicRecord};
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use async_trait::async_trait; use async_trait::async_trait;
@ -63,8 +62,7 @@ pub async fn save_topic_to_file(
} }
// 写入文件 // 写入文件
std::fs::write(&output_path, markdown) std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(output_path) Ok(output_path)
} }
@ -210,9 +208,10 @@ impl CommandHandler for SaveTopicCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::SaveTopic { filepath, include_subagents } => { Command::SaveTopic {
handle_save_topic(self, filepath, include_subagents, ctx).await filepath,
} include_subagents,
} => handle_save_topic(self, filepath, include_subagents, ctx).await,
_ => unreachable!(), _ => unreachable!(),
} }
} }
@ -249,14 +248,19 @@ async fn handle_save_topic(
.store .store
.get_topic(topic_id) .get_topic(topic_id)
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))? .map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; .ok_or_else(|| {
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
})?;
let messages = handler let messages = handler
.store .store
.load_messages_for_topic(topic_id, Some(&topic_record.session_id)) .load_messages_for_topic(topic_id, Some(&topic_record.session_id))
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?;
tracing::debug!(message_count = messages.len(), "Loaded messages from DB for topic"); tracing::debug!(
message_count = messages.len(),
"Loaded messages from DB for topic"
);
// 调用保存函数 // 调用保存函数
let output_path = save_topic_to_file( let output_path = save_topic_to_file(
@ -278,8 +282,14 @@ async fn handle_save_topic(
MessageKind::Notification, MessageKind::Notification,
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
// 统一转换为正斜杠以保证显示完整(跨平台兼容) // 统一转换为正斜杠以保证显示完整(跨平台兼容)
&format!("Topic saved to: {}", output_path.display().to_string().replace('\\', "/")), &format!(
"Topic saved to: {}",
output_path.display().to_string().replace('\\', "/")
),
)
.with_metadata(
"filepath",
&output_path.display().to_string().replace('\\', "/"),
) )
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
.with_metadata("message_count", &message_count.to_string())) .with_metadata("message_count", &message_count.to_string()))
} }

View File

@ -1,8 +1,8 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::list_topics::TopicSummary; use crate::command::handlers::list_topics::TopicSummary;
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
@ -56,7 +56,9 @@ impl CommandHandler for SessionCommandHandler {
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::CreateSession { title } => handle_create_session(self, title, ctx).await, Command::CreateSession { title } => handle_create_session(self, title, ctx).await,
Command::SaveSession { .. } => unreachable!("SaveSession should be handled by SaveSessionCommandHandler"), Command::SaveSession { .. } => {
unreachable!("SaveSession should be handled by SaveSessionCommandHandler")
}
_ => unreachable!("Other commands should be handled by other handlers"), _ => unreachable!("Other commands should be handled by other handlers"),
} }
} }
@ -69,13 +71,16 @@ async fn handle_create_session(
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
// 获取当前 session_id如果没有则报错 // 获取当前 session_id如果没有则报错
let session_id = ctx.session_id.as_deref() let session_id = ctx.session_id.as_deref().ok_or_else(|| {
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session. Please ensure a session exists first."))?; CommandError::new(
"NO_SESSION",
"No active session. Please ensure a session exists first.",
)
})?;
// 创建新话题(在同一个 Session 内) // 创建新话题(在同一个 Session 内)
let topic_title = title.unwrap_or_else(|| { let topic_title =
format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8]) title.unwrap_or_else(|| format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8]));
});
let topic = handler let topic = handler
.store .store
@ -83,14 +88,17 @@ async fn handle_create_session(
.map_err(|e| CommandError::new("CREATE_TOPIC_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("CREATE_TOPIC_ERROR", e.to_string()))?;
// 获取 chat_id // 获取 chat_id
let chat_id = ctx.chat_id.as_deref() let chat_id = ctx
.chat_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
// 如果有 SessionManager自动切换到新话题 // 如果有 SessionManager自动切换到新话题
if let Some(ref session_manager) = handler.session_manager { if let Some(ref session_manager) = handler.session_manager {
if let Some(session) = session_manager.get(&ctx.channel_name).await { if let Some(session) = session_manager.get(&ctx.channel_name).await {
let mut session_guard = session.lock().await; let mut session_guard = session.lock().await;
session_guard.switch_topic(chat_id, &topic.id) session_guard
.switch_topic(chat_id, &topic.id)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
} }
} }

View File

@ -1,9 +1,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::cancel_manager::CancelManager; use crate::gateway::cancel_manager::CancelManager;
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
@ -15,7 +15,10 @@ pub struct StopExecutionCommandHandler {
impl StopExecutionCommandHandler { impl StopExecutionCommandHandler {
pub fn new(cancel_manager: CancelManager, session_manager: SessionManager) -> Self { pub fn new(cancel_manager: CancelManager, session_manager: SessionManager) -> Self {
Self { cancel_manager, session_manager } Self {
cancel_manager,
session_manager,
}
} }
} }
@ -53,7 +56,11 @@ impl CommandHandler for StopExecutionCommandHandler {
None => { None => {
// 从 SessionManager 获取真实的 current topic // 从 SessionManager 获取真实的 current topic
let chat_id = ctx.chat_id.as_deref().unwrap_or(""); let chat_id = ctx.chat_id.as_deref().unwrap_or("");
match self.session_manager.get_current_topic(&ctx.channel_name, chat_id).await { match self
.session_manager
.get_current_topic(&ctx.channel_name, chat_id)
.await
{
Ok(Some(id)) => { Ok(Some(id)) => {
tracing::info!( tracing::info!(
channel = %ctx.channel_name, channel = %ctx.channel_name,
@ -65,12 +72,16 @@ impl CommandHandler for StopExecutionCommandHandler {
id id
} }
Ok(None) => { Ok(None) => {
return Ok(CommandResponse::success(ctx.request_id) return Ok(CommandResponse::success(ctx.request_id).with_message(
.with_message(MessageKind::Notification, "当前没有活跃的话题,无法停止")); MessageKind::Notification,
"当前没有活跃的话题,无法停止",
));
} }
Err(e) => { Err(e) => {
return Ok(CommandResponse::error(ctx.request_id, return Ok(CommandResponse::error(
CommandError::new("QUERY_TOPIC_ERROR", e.to_string()))); ctx.request_id,
CommandError::new("QUERY_TOPIC_ERROR", e.to_string()),
));
} }
} }
} }

View File

@ -1,7 +1,7 @@
use crate::command::Command;
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
use crate::storage::SessionStore; use crate::storage::SessionStore;
use async_trait::async_trait; use async_trait::async_trait;
@ -15,7 +15,10 @@ pub struct SwitchTopicCommandHandler {
impl SwitchTopicCommandHandler { impl SwitchTopicCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self { pub fn new(store: Arc<SessionStore>) -> Self {
Self { store, session_manager: None } Self {
store,
session_manager: None,
}
} }
pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self { pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self {
@ -44,9 +47,7 @@ impl CommandHandler for SwitchTopicCommandHandler {
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
match cmd { match cmd {
Command::SwitchTopic { topic_id } => { Command::SwitchTopic { topic_id } => handle_switch_topic(self, topic_id, ctx).await,
handle_switch_topic(self, topic_id, ctx).await
}
_ => unreachable!(), _ => unreachable!(),
} }
} }
@ -57,9 +58,13 @@ async fn handle_switch_topic(
topic_id: String, topic_id: String,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
let session_id = ctx.session_id.as_deref() let session_id = ctx
.session_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
let chat_id = ctx.chat_id.as_deref() let chat_id = ctx
.chat_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
// 尝试解析为序号 // 尝试解析为序号
@ -73,7 +78,11 @@ async fn handle_switch_topic(
if index >= topics.len() { if index >= topics.len() {
return Err(CommandError::new( return Err(CommandError::new(
"INVALID_TOPIC_INDEX", "INVALID_TOPIC_INDEX",
format!("Topic index {} is out of range (1-{})", index + 1, topics.len()) format!(
"Topic index {} is out of range (1-{})",
index + 1,
topics.len()
),
)); ));
} }
topics[index].id.clone() topics[index].id.clone()
@ -86,19 +95,26 @@ async fn handle_switch_topic(
.store .store
.get_topic(&target_topic_id) .get_topic(&target_topic_id)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))? .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", target_topic_id)))?; .ok_or_else(|| {
CommandError::new(
"TOPIC_NOT_FOUND",
format!("Topic not found: {}", target_topic_id),
)
})?;
// 如果有 SessionManager实际切换话题历史 // 如果有 SessionManager实际切换话题历史
if let Some(ref session_manager) = handler.session_manager { if let Some(ref session_manager) = handler.session_manager {
if let Some(session) = session_manager.get(&ctx.channel_name).await { if let Some(session) = session_manager.get(&ctx.channel_name).await {
let mut session_guard = session.lock().await; let mut session_guard = session.lock().await;
session_guard.switch_topic(chat_id, &target_topic_id) session_guard
.switch_topic(chat_id, &target_topic_id)
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
} }
} }
// 使用辅助方法获取消息数量 // 使用辅助方法获取消息数量
let msg_count = handler.store let msg_count = handler
.store
.get_topic_message_count(&target_topic_id) .get_topic_message_count(&target_topic_id)
.unwrap_or(0); .unwrap_or(0);

View File

@ -48,10 +48,7 @@ pub enum Command {
/// 列出所有定时任务 /// 列出所有定时任务
ListSchedulerJobs, ListSchedulerJobs,
/// 加载指定 channel + chat_id 的对话消息 /// 加载指定 channel + chat_id 的对话消息
LoadChatMessages { LoadChatMessages { channel: String, chat_id: String },
channel: String,
chat_id: String,
},
/// 删除指定话题 /// 删除指定话题
DeleteTopic { topic_id: String }, DeleteTopic { topic_id: String },
/// 重命名指定话题 /// 重命名指定话题
@ -67,10 +64,7 @@ pub enum Command {
content: String, content: String,
}, },
/// 更新已有记忆 /// 更新已有记忆
UpdateMemory { UpdateMemory { id: String, content: String },
id: String,
content: String,
},
/// 删除记忆 /// 删除记忆
DeleteMemory { id: String }, DeleteMemory { id: String },
/// 列出所有技能 /// 列出所有技能

View File

@ -1061,7 +1061,10 @@ pub struct ModelResolver {
} }
impl ModelResolver { impl ModelResolver {
pub fn new(providers: HashMap<String, ProviderConfig>, models: HashMap<String, ModelConfig>) -> Self { pub fn new(
providers: HashMap<String, ProviderConfig>,
models: HashMap<String, ModelConfig>,
) -> Self {
Self { providers, models } Self { providers, models }
} }
@ -1156,7 +1159,8 @@ fn resolve_env_placeholders(content: &str) -> String {
env::var(var_name).unwrap_or_else(|_| caps[0].to_string()) env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
}); });
re_angle.replace_all(&content, |caps: &regex::Captures| { re_angle
.replace_all(&content, |caps: &regex::Captures| {
let var_name = &caps[1]; let var_name = &caps[1];
env::var(var_name).unwrap_or_else(|_| caps[0].to_string()) env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
}) })
@ -1738,7 +1742,8 @@ mod tests {
"allow_from": ["wxid_1"] "allow_from": ["wxid_1"]
} }
} }
}"#.replace("<CRED_PATH>", &cred_path_json), }"#
.replace("<CRED_PATH>", &cred_path_json),
) )
.unwrap(); .unwrap();

View File

@ -12,7 +12,9 @@ static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)] #[cfg(test)]
pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> { pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
EXPERT_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()) EXPERT_TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner())
} }
/// A discovered expert definition. /// A discovered expert definition.
@ -291,13 +293,20 @@ impl ExpertRuntime {
/// Re-discover experts from the filesystem. /// Re-discover experts from the filesystem.
pub fn reload(&self) -> Result<ExpertCatalog, String> { pub fn reload(&self) -> Result<ExpertCatalog, String> {
let config = self.config.read().expect("experts config rwlock poisoned").clone(); let config = self
.config
.read()
.expect("experts config rwlock poisoned")
.clone();
let catalog = ExpertCatalog::discover_with_state( let catalog = ExpertCatalog::discover_with_state(
&config, &config,
&self.cwd, &self.cwd,
Some(&load_expert_disable_state(&self.cwd)), Some(&load_expert_disable_state(&self.cwd)),
); );
let mut guard = self.catalog.write().expect("experts catalog rwlock poisoned"); let mut guard = self
.catalog
.write()
.expect("experts catalog rwlock poisoned");
*guard = catalog.clone(); *guard = catalog.clone();
Ok(catalog) Ok(catalog)
} }
@ -324,7 +333,11 @@ impl ExpertRuntime {
/// List all discovered experts including disabled ones, with their disabled scopes. /// List all discovered experts including disabled ones, with their disabled scopes.
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> { pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
let config = self.config.read().expect("experts config rwlock poisoned").clone(); let config = self
.config
.read()
.expect("experts config rwlock poisoned")
.clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
let disable_state = load_expert_disable_state(&self.cwd); let disable_state = load_expert_disable_state(&self.cwd);
@ -411,7 +424,15 @@ impl ExpertRuntime {
let next_provider = provider.cloned().unwrap_or(existing.provider); let next_provider = provider.cloned().unwrap_or(existing.provider);
let next_model = model.cloned().unwrap_or(existing.model); let next_model = model.cloned().unwrap_or(existing.model);
write_expert_file(&path, name, next_description, next_body, &next_capability, &next_provider, &next_model)?; write_expert_file(
&path,
name,
next_description,
next_body,
&next_capability,
&next_provider,
&next_model,
)?;
let expert = parse_expert_file(&path, scope.into())?; let expert = parse_expert_file(&path, scope.into())?;
if reload { if reload {
let _ = self.reload()?; let _ = self.reload()?;
@ -457,7 +478,11 @@ impl ExpertRuntime {
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> { pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
validate_expert_name(name)?; validate_expert_name(name)?;
let config = self.config.read().expect("experts config rwlock poisoned").clone(); let config = self
.config
.read()
.expect("experts config rwlock poisoned")
.clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
Ok(catalog.find_expert(name).is_some()) Ok(catalog.find_expert(name).is_some())
} }
@ -536,10 +561,7 @@ impl ExpertRuntime {
} }
// The expert must exist (and not be disabled) for selection to be meaningful. // The expert must exist (and not be disabled) for selection to be meaningful.
if self.get_expert(expert_name).is_none() { if self.get_expert(expert_name).is_none() {
return Err(format!( return Err(format!("expert '{}' not found or disabled", expert_name));
"expert '{}' not found or disabled",
expert_name
));
} }
{ {
@ -624,10 +646,7 @@ impl SystemPromptProvider for ExpertPromptProvider {
let content = if expert.body.trim().is_empty() { let content = if expert.body.trim().is_empty() {
// Empty body is OK; inject a header so the LLM still knows the role. // Empty body is OK; inject a header so the LLM still knows the role.
format!( format!("# 专家角色: {}\n\n{}", expert.name, expert.description)
"# 专家角色: {}\n\n{}",
expert.name, expert.description
)
} else { } else {
expert.body.clone() expert.body.clone()
}; };
@ -667,8 +686,9 @@ fn expert_state_path(scope: ExpertScope, cwd: &Path) -> PathBuf {
fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result<PathBuf, String> { fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result<PathBuf, String> {
match scope { match scope {
ExpertScope::User => user_experts_root() ExpertScope::User => {
.ok_or_else(|| "failed to resolve home directory".to_string()), user_experts_root().ok_or_else(|| "failed to resolve home directory".to_string())
}
ExpertScope::Project => Ok(project_experts_root(cwd)), ExpertScope::Project => Ok(project_experts_root(cwd)),
} }
} }
@ -1020,7 +1040,10 @@ fn load_project_session_experts(cwd: &Path) -> HashMap<String, String> {
/// Persist a mutation to the project-scope state file's session_experts while /// Persist a mutation to the project-scope state file's session_experts while
/// preserving the existing disabled_experts field. /// preserving the existing disabled_experts field.
fn persist_session_experts<F: FnOnce(&mut ExpertStateFile)>(cwd: &Path, mutate: F) -> Result<(), String> { fn persist_session_experts<F: FnOnce(&mut ExpertStateFile)>(
cwd: &Path,
mutate: F,
) -> Result<(), String> {
let path = project_expert_state_path(cwd); let path = project_expert_state_path(cwd);
let mut state = load_expert_state_file(&path)?; let mut state = load_expert_state_file(&path)?;
mutate(&mut state); mutate(&mut state);
@ -1097,7 +1120,11 @@ mod tests {
let expert_dir = dir.path().join("demo"); let expert_dir = dir.path().join("demo");
fs::create_dir_all(&expert_dir).unwrap(); fs::create_dir_all(&expert_dir).unwrap();
let expert_md = expert_dir.join("EXPERT.md"); let expert_md = expert_dir.join("EXPERT.md");
fs::write(&expert_md, "---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B").unwrap(); fs::write(
&expert_md,
"---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B",
)
.unwrap();
let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap(); let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap();
assert_eq!(expert.name, "demo"); assert_eq!(expert.name, "demo");
@ -1137,33 +1164,49 @@ mod tests {
#[test] #[test]
fn test_render_expert_file_requires_description() { fn test_render_expert_file_requires_description() {
let err = render_expert_file("demo", " ", "body", &CapabilityPolicy::default(), &None, &None).unwrap_err(); let err = render_expert_file(
"demo",
" ",
"body",
&CapabilityPolicy::default(),
&None,
&None,
)
.unwrap_err();
assert!(err.contains("description")); assert!(err.contains("description"));
} }
#[test] #[test]
fn test_capability_policy_is_empty_helpers() { fn test_capability_policy_is_empty_helpers() {
assert!(CapabilityPolicy::default().is_empty()); assert!(CapabilityPolicy::default().is_empty());
assert!(!CapabilityPolicy { assert!(
!CapabilityPolicy {
allowed_skills: Some(vec!["a".to_string()]), allowed_skills: Some(vec!["a".to_string()]),
..Default::default() ..Default::default()
} }
.is_empty()); .is_empty()
assert!(CapabilityPolicy { );
assert!(
CapabilityPolicy {
denied_tools: vec![], denied_tools: vec![],
..Default::default() ..Default::default()
} }
.is_empty()); .is_empty()
assert!(CapabilityPolicy { );
assert!(
CapabilityPolicy {
allowed_tools: Some(vec![]), allowed_tools: Some(vec![]),
..Default::default() ..Default::default()
} }
.has_tool_policy()); .has_tool_policy()
assert!(CapabilityPolicy { );
assert!(
CapabilityPolicy {
denied_skills: vec!["x".to_string()], denied_skills: vec!["x".to_string()],
..Default::default() ..Default::default()
} }
.has_skill_policy()); .has_skill_policy()
);
} }
#[test] #[test]
@ -1196,7 +1239,15 @@ mod tests {
#[test] #[test]
fn test_empty_capability_omits_keys() { fn test_empty_capability_omits_keys() {
// 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容 // 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容
let rendered = render_expert_file("plain", "desc", "body", &CapabilityPolicy::default(), &None, &None).unwrap(); let rendered = render_expert_file(
"plain",
"desc",
"body",
&CapabilityPolicy::default(),
&None,
&None,
)
.unwrap();
assert!(!rendered.contains("allowed_skills")); assert!(!rendered.contains("allowed_skills"));
assert!(!rendered.contains("denied_skills")); assert!(!rendered.contains("denied_skills"));
assert!(!rendered.contains("allowed_tools")); assert!(!rendered.contains("allowed_tools"));
@ -1224,10 +1275,7 @@ mod tests {
.unwrap(); .unwrap();
// project scope (overrides user) // project scope (overrides user)
let project_dir_expert = project_dir let project_dir_expert = project_dir.join(".picobot").join("experts").join("demo");
.join(".picobot")
.join("experts")
.join("demo");
fs::create_dir_all(&project_dir_expert).unwrap(); fs::create_dir_all(&project_dir_expert).unwrap();
fs::write( fs::write(
project_dir_expert.join("EXPERT.md"), project_dir_expert.join("EXPERT.md"),
@ -1310,7 +1358,16 @@ mod tests {
// update with None preserves fields // update with None preserves fields
let updated_none = runtime let updated_none = runtime
.update_expert(ExpertScope::Project, "translator", None, None, None, None, None, true) .update_expert(
ExpertScope::Project,
"translator",
None,
None,
None,
None,
None,
true,
)
.unwrap(); .unwrap();
assert_eq!(updated_none.description, "更新翻译专家"); assert_eq!(updated_none.description, "更新翻译专家");
assert_eq!(updated_none.body, "你是一名中文教师。"); assert_eq!(updated_none.body, "你是一名中文教师。");
@ -1509,13 +1566,21 @@ mod tests {
.unwrap(); .unwrap();
let items = runtime.list_experts_with_status(); let items = runtime.list_experts_with_status();
assert_eq!(items.len(), 1, "list_experts_with_status should include disabled experts"); assert_eq!(
items.len(),
1,
"list_experts_with_status should include disabled experts"
);
assert_eq!(items[0].name, "planner"); assert_eq!(items[0].name, "planner");
assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]); assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]);
// list_experts (filtered) should be empty // list_experts (filtered) should be empty
let active = runtime.list_experts(); let active = runtime.list_experts();
assert_eq!(active.len(), 0, "list_experts should filter out disabled experts"); assert_eq!(
active.len(),
0,
"list_experts should filter out disabled experts"
);
} }
#[test] #[test]
@ -1526,7 +1591,9 @@ mod tests {
session_experts: HashMap::new(), session_experts: HashMap::new(),
disabled_experts: vec!["demo".to_string()], disabled_experts: vec!["demo".to_string()],
}; };
state.session_experts.insert("sess-1".to_string(), "demo".to_string()); state
.session_experts
.insert("sess-1".to_string(), "demo".to_string());
save_expert_state_file(&path, &state).unwrap(); save_expert_state_file(&path, &state).unwrap();

View File

@ -1,5 +1,5 @@
use gray_matter::engine::YAML;
use gray_matter::Matter; use gray_matter::Matter;
use gray_matter::engine::YAML;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
/// Parse a markdown document with YAML frontmatter into `(frontmatter, body)`. /// Parse a markdown document with YAML frontmatter into `(frontmatter, body)`.
@ -45,7 +45,13 @@ mod tests {
fn parses_lf_endings() { fn parses_lf_endings() {
let input = "---\ndescription: demo\n---\nbody text"; let input = "---\ndescription: demo\n---\nbody text";
let (fm, body) = parse::<FrontMatter>(input).unwrap(); let (fm, body) = parse::<FrontMatter>(input).unwrap();
assert_eq!(fm, FrontMatter { description: "demo".to_string(), name: None }); assert_eq!(
fm,
FrontMatter {
description: "demo".to_string(),
name: None
}
);
assert_eq!(body, "body text"); assert_eq!(body, "body text");
} }

View File

@ -9,8 +9,8 @@ use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::gateway::model_selection::ModelSelectionStore; use crate::gateway::model_selection::ModelSelectionStore;
use crate::gateway::tool_prompt_provider::ToolPromptProvider; use crate::gateway::tool_prompt_provider::ToolPromptProvider;
use crate::skills::{SkillPromptProvider, SkillRuntime}; use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::persistent_session_id;
use crate::storage::PromptInjectionRepository; use crate::storage::PromptInjectionRepository;
use crate::storage::persistent_session_id;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime}; use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::tools::{ToolContext, ToolRegistry}; use crate::tools::{ToolContext, ToolRegistry};
@ -112,7 +112,9 @@ impl AgentFactory {
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。 // 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
let expert_provider_config = match &expert { let expert_provider_config = match &expert {
Some(e) if e.provider.is_some() || e.model.is_some() => { Some(e) if e.provider.is_some() || e.model.is_some() => {
let resolved = self.model_resolver.resolve( let resolved = self
.model_resolver
.resolve(
e.provider.as_deref(), e.provider.as_deref(),
e.model.as_deref(), e.model.as_deref(),
&request.provider_config, &request.provider_config,
@ -133,8 +135,7 @@ impl AgentFactory {
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。 // 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。 // 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
let effective_provider_config = let effective_provider_config = match self.model_selections.get(&session_id) {
match self.model_selections.get(&session_id) {
Some((user_provider, user_model)) Some((user_provider, user_model))
if user_provider.is_some() || user_model.is_some() => if user_provider.is_some() || user_model.is_some() =>
{ {

View File

@ -40,7 +40,13 @@ impl AgentTaskExecutor {
options: ScheduledAgentTaskOptions, options: ScheduledAgentTaskOptions,
) -> Result<Vec<OutboundMessage>, AgentError> { ) -> Result<Vec<OutboundMessage>, AgentError> {
self.session_manager self.session_manager
.run_silent_agent_task(channel_name, session_chat_id, notification_chat_id, prompt, options) .run_silent_agent_task(
channel_name,
session_chat_id,
notification_chat_id,
prompt,
options,
)
.await .await
} }
} }
@ -93,8 +99,12 @@ impl SchedulerMaintenanceService {
self.session_manager.cleanup_expired_sessions().await self.session_manager.cleanup_expired_sessions().await
} }
async fn run_memory_maintenance(&self) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> { async fn run_memory_maintenance(
self.session_manager.run_memory_maintenance_for_all_scopes().await &self,
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
self.session_manager
.run_memory_maintenance_for_all_scopes()
.await
} }
} }
@ -104,7 +114,9 @@ impl MaintenanceExecutor for SchedulerMaintenanceService {
self.cleanup_sessions().await self.cleanup_sessions().await
} }
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>> { async fn run_memory_maintenance_for_all_scopes(
&self,
) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
self.run_memory_maintenance() self.run_memory_maintenance()
.await .await
.map(|results| { .map(|results| {

View File

@ -1,12 +1,15 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use crate::agent::{
use crate::agent::{AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPromptContext}; AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler,
SystemPromptContext,
};
use crate::bus::message::ToolMessageState; use crate::bus::message::ToolMessageState;
use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT}; use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::storage::{persistent_session_id, ConversationRepository}; use crate::storage::{ConversationRepository, persistent_session_id};
use async_trait::async_trait;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use super::compaction::schedule_background_history_compaction; use super::compaction::schedule_background_history_compaction;
@ -100,9 +103,12 @@ impl AgentExecutionService {
}; };
if !is_current_turn { if !is_current_turn {
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = session
session.stale_result_diagnostics( .stale_result_diagnostics(
request.original_topic_id.as_deref().unwrap_or(request.chat_id), request
.original_topic_id
.as_deref()
.unwrap_or(request.chat_id),
); );
tracing::info!( tracing::info!(
channel = %request.channel_name, channel = %request.channel_name,
@ -126,10 +132,9 @@ impl AgentExecutionService {
if let Some(topic_id) = target_topic_id { if let Some(topic_id) = target_topic_id {
if is_current_turn { if is_current_turn {
// 话题未切换current_topic == original_topic_id安全更新内存历史 // 话题未切换current_topic == original_topic_id安全更新内存历史
if let Err(err) = session.append_persisted_messages( if let Err(err) = session
topic_id, .append_persisted_messages(topic_id, request.result.emitted_messages.clone())
request.result.emitted_messages.clone(), {
) {
tracing::error!( tracing::error!(
error = %err, error = %err,
topic_id = %topic_id, topic_id = %topic_id,
@ -153,10 +158,9 @@ impl AgentExecutionService {
} else if is_current_turn { } else if is_current_turn {
// 没有话题直接更新内存历史append_persisted_messages 会处理持久化) // 没有话题直接更新内存历史append_persisted_messages 会处理持久化)
// 无 topic 场景用 chat_id 作为 topic_histories 的回退 key // 无 topic 场景用 chat_id 作为 topic_histories 的回退 key
if let Err(err) = session.append_persisted_messages( if let Err(err) = session
request.chat_id, .append_persisted_messages(request.chat_id, request.result.emitted_messages.clone())
request.result.emitted_messages.clone(), {
) {
tracing::error!( tracing::error!(
error = %err, error = %err,
chat_id = %request.chat_id, chat_id = %request.chat_id,
@ -274,7 +278,13 @@ impl AgentExecutionService {
agent = agent.with_emitted_message_handler(handler); agent = agent.with_emitted_message_handler(handler);
} }
(history, agent, user_message, user_message_count, original_topic_id) (
history,
agent,
user_message,
user_message_count,
original_topic_id,
)
}; };
// 构建系统提示词上下文 // 构建系统提示词上下文
@ -324,7 +334,15 @@ impl AgentExecutionService {
// 等待该 topic 的前一条消息处理完成(含压缩) // 等待该 topic 的前一条消息处理完成(含压缩)
let _serial_guard = serial_lock.lock().await; let _serial_guard = serial_lock.lock().await;
let (history, mut agent, user_message, user_message_count, original_topic_id, store, session_id) = { let (
history,
mut agent,
user_message,
user_message_count,
original_topic_id,
store,
session_id,
) = {
let mut session_guard = request.session.lock().await; let mut session_guard = request.session.lock().await;
session_guard.ensure_persistent_session(request.chat_id)?; session_guard.ensure_persistent_session(request.chat_id)?;
@ -382,12 +400,18 @@ impl AgentExecutionService {
// 获取 store 和 session_id用于构造消息持久化 handler // 获取 store 和 session_id用于构造消息持久化 handler
let store = session_guard.store(); let store = session_guard.store();
let session_id = crate::storage::persistent_session_id( let session_id =
request.channel_name, crate::storage::persistent_session_id(request.channel_name, request.chat_id);
request.chat_id,
);
(history, agent, user_message, user_message_count, original_topic_id, store, session_id) (
history,
agent,
user_message,
user_message_count,
original_topic_id,
store,
session_id,
)
}; };
// 定时任务没有 live_emitter需要 PersistingEmittedMessageHandler 来持久化消息 // 定时任务没有 live_emitter需要 PersistingEmittedMessageHandler 来持久化消息
@ -410,7 +434,8 @@ impl AgentExecutionService {
let result = agent.process(history, Some(&system_prompt_context)).await?; let result = agent.process(history, Some(&system_prompt_context)).await?;
let outbound_messages = self.finalize_result_and_schedule_compaction( let outbound_messages = self
.finalize_result_and_schedule_compaction(
request.session.clone(), request.session.clone(),
FinalizeAgentResultRequest { FinalizeAgentResultRequest {
channel_name: request.channel_name, channel_name: request.channel_name,
@ -543,11 +568,7 @@ mod tests {
let _guard1 = lock.lock().await; let _guard1 = lock.lock().await;
// 第二次获取应阻塞1ms 超时验证 // 第二次获取应阻塞1ms 超时验证
let result = tokio::time::timeout( let result = tokio::time::timeout(std::time::Duration::from_millis(1), lock.lock()).await;
std::time::Duration::from_millis(1),
lock.lock(),
)
.await;
assert!(result.is_err(), "第二次获取同一锁应阻塞"); assert!(result.is_err(), "第二次获取同一锁应阻塞");
} }
@ -561,11 +582,8 @@ mod tests {
let _guard_a = lock_a.lock().await; let _guard_a = lock_a.lock().await;
// 不同锁应立即可获取 // 不同锁应立即可获取
let result = tokio::time::timeout( let result =
std::time::Duration::from_millis(100), tokio::time::timeout(std::time::Duration::from_millis(100), lock_b.lock()).await;
lock_b.lock(),
)
.await;
assert!(result.is_ok(), "不同 topic 的锁应互不影响"); assert!(result.is_ok(), "不同 topic 的锁应互不影响");
} }
@ -585,11 +603,7 @@ mod tests {
} }
// 锁应已释放,可再次获取 // 锁应已释放,可再次获取
let result = tokio::time::timeout( let result = tokio::time::timeout(std::time::Duration::from_millis(100), lock.lock()).await;
std::time::Duration::from_millis(100),
lock.lock(),
)
.await;
assert!(result.is_ok(), "错误返回后锁应已释放"); assert!(result.is_ok(), "错误返回后锁应已释放");
} }

View File

@ -1,5 +1,8 @@
use axum::{Json, extract::{Query, State}};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::{
Json,
extract::{Query, State},
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
@ -90,9 +93,7 @@ pub struct SaveConfigResponse {
} }
/// GET /api/config — Return current config with masked sensitive fields /// GET /api/config — Return current config with masked sensitive fields
pub async fn get_config( pub async fn get_config(State(state): State<Arc<GatewayState>>) -> Json<Config> {
State(state): State<Arc<GatewayState>>,
) -> Json<Config> {
Json(mask_config(&*state.config.read().await)) Json(mask_config(&*state.config.read().await))
} }
@ -138,11 +139,19 @@ pub async fn save_config(
.unwrap_or_else(|_| get_default_config_path()); .unwrap_or_else(|_| get_default_config_path());
// Serialize and write to disk (no lock held) // Serialize and write to disk (no lock held)
let json = serde_json::to_string_pretty(&new_config) let json = serde_json::to_string_pretty(&new_config).map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialize error: {}", e)))?; (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Serialize error: {}", e),
)
})?;
std::fs::write(&config_path, &json) std::fs::write(&config_path, &json).map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)))?; (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Write error: {}", e),
)
})?;
// Update in-memory config (write lock, held only for assignment) // Update in-memory config (write lock, held only for assignment)
{ {
@ -226,9 +235,7 @@ pub async fn mcp_status(
} }
/// GET /api/skills — Return all discovered skills with their disabled status /// GET /api/skills — Return all discovered skills with their disabled status
pub async fn skills_list( pub async fn skills_list(State(state): State<Arc<GatewayState>>) -> Json<SkillListResponse> {
State(state): State<Arc<GatewayState>>,
) -> Json<SkillListResponse> {
let skills_enabled = state.config.read().await.skills.enabled; let skills_enabled = state.config.read().await.skills.enabled;
if !skills_enabled { if !skills_enabled {
@ -281,9 +288,7 @@ pub struct CurrentModel {
/// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source. /// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source.
/// 通过 SessionManager::tools() 只读访问 ToolRegistry不修改状态。 /// 通过 SessionManager::tools() 只读访问 ToolRegistry不修改状态。
pub async fn tools_list( pub async fn tools_list(State(state): State<Arc<GatewayState>>) -> Json<ToolsListResponse> {
State(state): State<Arc<GatewayState>>,
) -> Json<ToolsListResponse> {
let registry = state.session_manager.tools(); let registry = state.session_manager.tools();
let tools: Vec<ToolInfo> = registry let tools: Vec<ToolInfo> = registry
.get_definitions() .get_definitions()
@ -312,9 +317,7 @@ pub async fn tools_list(
} }
/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。 /// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。
pub async fn model_options( pub async fn model_options(State(state): State<Arc<GatewayState>>) -> Json<ModelOptionsResponse> {
State(state): State<Arc<GatewayState>>,
) -> Json<ModelOptionsResponse> {
let config = state.config.read().await; let config = state.config.read().await;
let resolver = crate::config::ModelResolver::from_config(&config); let resolver = crate::config::ModelResolver::from_config(&config);
// 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key // 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key
@ -371,7 +374,11 @@ pub async fn skills_toggle(
changed: Some(change.changed), changed: Some(change.changed),
available: Some(change.available), available: Some(change.available),
disabled_in_scopes: Some( disabled_in_scopes: Some(
change.disabled_in_scopes.iter().map(|s| s.as_str().to_string()).collect(), change
.disabled_in_scopes
.iter()
.map(|s| s.as_str().to_string())
.collect(),
), ),
error: None, error: None,
}), }),
@ -424,9 +431,7 @@ pub struct SubagentListResponse {
} }
/// GET /api/subagents — Return all discovered subagents with their disabled status /// GET /api/subagents — Return all discovered subagents with their disabled status
pub async fn subagents_list( pub async fn subagents_list(State(state): State<Arc<GatewayState>>) -> Json<SubagentListResponse> {
State(state): State<Arc<GatewayState>>,
) -> Json<SubagentListResponse> {
let subagents_enabled = state.config.read().await.subagents.enabled; let subagents_enabled = state.config.read().await.subagents.enabled;
if !subagents_enabled { if !subagents_enabled {
@ -725,9 +730,7 @@ pub struct ExpertDeleteResponse {
} }
/// GET /api/experts — Return all discovered experts with their disabled status /// GET /api/experts — Return all discovered experts with their disabled status
pub async fn experts_list( pub async fn experts_list(State(state): State<Arc<GatewayState>>) -> Json<ExpertListResponse> {
State(state): State<Arc<GatewayState>>,
) -> Json<ExpertListResponse> {
let experts_enabled = state.config.read().await.experts.enabled; let experts_enabled = state.config.read().await.experts.enabled;
if !experts_enabled { if !experts_enabled {
@ -817,8 +820,12 @@ pub async fn experts_create(
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertCreateRequest>, Json(req): Json<ExpertCreateRequest>,
) -> Result<Json<ExpertResponse>, (StatusCode, String)> { ) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
let scope = ExpertScope::parse(&req.scope) let scope = ExpertScope::parse(&req.scope).ok_or_else(|| {
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?; (
StatusCode::BAD_REQUEST,
format!("invalid scope: {}", req.scope),
)
})?;
let expert = state let expert = state
.experts .experts
@ -849,8 +856,12 @@ pub async fn experts_update(
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertUpdateRequest>, Json(req): Json<ExpertUpdateRequest>,
) -> Result<Json<ExpertResponse>, (StatusCode, String)> { ) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
let scope = ExpertScope::parse(&req.scope) let scope = ExpertScope::parse(&req.scope).ok_or_else(|| {
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?; (
StatusCode::BAD_REQUEST,
format!("invalid scope: {}", req.scope),
)
})?;
let expert = state let expert = state
.experts .experts
@ -1011,9 +1022,7 @@ pub async fn session_select_model(
} }
drop(config); drop(config);
state state.model_selections.set(&req.session_id, provider, model);
.model_selections
.set(&req.session_id, provider, model);
( (
StatusCode::OK, StatusCode::OK,
Json(SelectModelResponse { Json(SelectModelResponse {

View File

@ -227,8 +227,8 @@ impl MemoryMaintenanceService {
Ok(parsed) => return Ok(parsed), Ok(parsed) => return Ok(parsed),
Err(err) => { Err(err) => {
let error_msg = err.to_string(); let error_msg = err.to_string();
let is_truncated = error_msg.contains("EOF while parsing") let is_truncated =
|| error_msg.contains("expected"); error_msg.contains("EOF while parsing") || error_msg.contains("expected");
let should_retry = delay_ms.is_some() && is_truncated; let should_retry = delay_ms.is_some() && is_truncated;
last_error = Some(error_msg.clone()); last_error = Some(error_msg.clone());
@ -369,9 +369,10 @@ impl MemoryMaintenanceService {
pub(crate) async fn run_for_all_scopes( pub(crate) async fn run_for_all_scopes(
&self, &self,
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> { ) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
let scope_keys = self.store.list_memory_scope_keys("user").map_err(|err| { let scope_keys = self
AgentError::Other(format!("list memory scope keys error: {}", err)) .store
})?; .list_memory_scope_keys("user")
.map_err(|err| AgentError::Other(format!("list memory scope keys error: {}", err)))?;
if scope_keys.is_empty() { if scope_keys.is_empty() {
return Ok(None); return Ok(None);
@ -418,7 +419,8 @@ impl MemoryMaintenanceService {
let managed_markdown = if all_remaining_memories.is_empty() { let managed_markdown = if all_remaining_memories.is_empty() {
String::new() String::new()
} else { } else {
self.generate_summary("all", &all_remaining_memories).await? self.generate_summary("all", &all_remaining_memories)
.await?
}; };
if !managed_markdown.is_empty() { if !managed_markdown.is_empty() {
@ -678,24 +680,29 @@ pub(crate) fn validate_memory_maintenance_output(
} }
// 验证 2: 跨 namespace 合并检测(完全禁止) // 验证 2: 跨 namespace 合并检测(完全禁止)
let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> = plan let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> =
.candidates plan.candidates.iter().map(|c| (c.id.as_str(), c)).collect();
.iter()
.map(|c| (c.id.as_str(), c))
.collect();
for merge in &output.merges { for merge in &output.merges {
let source_namespaces: HashSet<&str> = merge let source_namespaces: HashSet<&str> = merge
.source_ids .source_ids
.iter() .iter()
.filter_map(|id| candidates_by_id.get(id.as_str()).map(|c| c.namespace.as_str())) .filter_map(|id| {
candidates_by_id
.get(id.as_str())
.map(|c| c.namespace.as_str())
})
.collect(); .collect();
// 检查是否跨越多个 namespace // 检查是否跨越多个 namespace
if source_namespaces.len() > 1 { if source_namespaces.len() > 1 {
return Err(format!( return Err(format!(
"跨 namespace 合并被禁止: 源来自 {}", "跨 namespace 合并被禁止: 源来自 {}",
source_namespaces.iter().cloned().collect::<Vec<_>>().join(", ") source_namespaces
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)); ));
} }
@ -718,11 +725,7 @@ pub(crate) fn validate_memory_maintenance_output(
.map(|s| s.as_str()) .map(|s| s.as_str())
.collect(); .collect();
let deleted_ids: HashSet<&str> = output let deleted_ids: HashSet<&str> = output.low_value_ids.iter().map(|s| s.as_str()).collect();
.low_value_ids
.iter()
.map(|s| s.as_str())
.collect();
let affected = merged_ids.len() + deleted_ids.len(); let affected = merged_ids.len() + deleted_ids.len();
let max_allowed = (total as f32 * max_merge_ratio).ceil() as usize; let max_allowed = (total as f32 * max_merge_ratio).ceil() as usize;
@ -758,7 +761,13 @@ pub(crate) fn apply_memory_maintenance_output(
max_merge_per_group: usize, max_merge_per_group: usize,
) -> Result<(), AgentError> { ) -> Result<(), AgentError> {
// 新增: 验证合并输出 // 新增: 验证合并输出
validate_memory_maintenance_output(plan, output, max_merge_ratio, min_memories_to_keep, max_merge_per_group) validate_memory_maintenance_output(
plan,
output,
max_merge_ratio,
min_memories_to_keep,
max_merge_per_group,
)
.map_err(|e| AgentError::Other(e))?; .map_err(|e| AgentError::Other(e))?;
let all_candidates = plan.candidates.clone(); let all_candidates = plan.candidates.clone();

View File

@ -25,8 +25,8 @@ pub mod session_message_sender;
pub mod session_message_service; pub mod session_message_service;
pub mod session_pool; pub mod session_pool;
pub mod static_files; pub mod static_files;
pub mod tool_registry_factory;
pub mod tool_prompt_provider; pub mod tool_prompt_provider;
pub mod tool_registry_factory;
pub mod ws; pub mod ws;
use axum::{Router, routing}; use axum::{Router, routing};
@ -50,11 +50,11 @@ use cancel_manager::CancelManager;
use outbound_dispatcher::OutboundDispatcher; use outbound_dispatcher::OutboundDispatcher;
use processor::InboundProcessor; use processor::InboundProcessor;
use runtime::build_session_manager_with_sender; use runtime::build_session_manager_with_sender;
use session_message_sender::BusSessionMessageSender;
use session::SessionManager; use session::SessionManager;
use session_message_sender::BusSessionMessageSender;
use static_files::static_handler; use static_files::static_handler;
use tokio::sync::{watch, RwLock}; use tokio::sync::{RwLock, watch};
pub struct GatewayState { pub struct GatewayState {
pub config: Arc<RwLock<Config>>, pub config: Arc<RwLock<Config>>,
@ -73,7 +73,10 @@ pub struct GatewayState {
} }
impl GatewayState { impl GatewayState {
pub fn from_config(config: Config, restart_tx: watch::Sender<bool>) -> Result<Self, Box<dyn std::error::Error>> { pub fn from_config(
config: Config,
restart_tx: watch::Sender<bool>,
) -> Result<Self, Box<dyn std::error::Error>> {
// Get provider config for SessionManager // Get provider config for SessionManager
let provider_config = config.get_provider_config("default")?; let provider_config = config.get_provider_config("default")?;
let mut provider_configs = HashMap::<String, LLMProviderConfig>::new(); let mut provider_configs = HashMap::<String, LLMProviderConfig>::new();
@ -87,7 +90,9 @@ impl GatewayState {
let session_ttl_hours = config.gateway.session_ttl_hours; let session_ttl_hours = config.gateway.session_ttl_hours;
let skills = Arc::new(SkillRuntime::from_config(config.skills.clone())); let skills = Arc::new(SkillRuntime::from_config(config.skills.clone()));
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(config.experts.clone())); let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
config.experts.clone(),
));
let channel_manager = ChannelManager::new(); let channel_manager = ChannelManager::new();
let bus = channel_manager.bus(); let bus = channel_manager.bus();
@ -95,7 +100,8 @@ impl GatewayState {
mcp_servers: config.mcp_servers.clone(), mcp_servers: config.mcp_servers.clone(),
}; };
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) = build_session_manager_with_sender( let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) =
build_session_manager_with_sender(
agent_prompt_reinject_every, agent_prompt_reinject_every,
show_tool_results, show_tool_results,
config.time.timezone.clone(), config.time.timezone.clone(),
@ -155,8 +161,13 @@ impl GatewayState {
drop(cfg); // release read lock before spawning long-running tasks drop(cfg); // release read lock before spawning long-running tasks
let semaphore = Arc::new(Semaphore::new(max_concurrent)); let semaphore = Arc::new(Semaphore::new(max_concurrent));
let inbound_processor = let inbound_processor = InboundProcessor::new(
InboundProcessor::new(self.bus.clone(), self.session_manager.clone(), semaphore, provider_config, self.cancel_manager.clone()); self.bus.clone(),
self.session_manager.clone(),
semaphore,
provider_config,
self.cancel_manager.clone(),
);
tokio::spawn(inbound_processor.run()); tokio::spawn(inbound_processor.run());
// Spawn outbound dispatcher // Spawn outbound dispatcher
@ -241,7 +252,10 @@ pub async fn run(
let app = if use_embedded { let app = if use_embedded {
Router::new() Router::new()
.route("/health", routing::get(http::health)) .route("/health", routing::get(http::health))
.route("/api/config", routing::get(http::get_config).put(http::save_config)) .route(
"/api/config",
routing::get(http::get_config).put(http::save_config),
)
.route("/api/restart", routing::post(http::restart)) .route("/api/restart", routing::post(http::restart))
.route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list)) .route("/api/skills", routing::get(http::skills_list))
@ -249,17 +263,32 @@ pub async fn run(
.route("/api/tools", routing::get(http::tools_list)) .route("/api/tools", routing::get(http::tools_list))
.route("/api/model-options", routing::get(http::model_options)) .route("/api/model-options", routing::get(http::model_options))
.route("/api/subagents", routing::get(http::subagents_list)) .route("/api/subagents", routing::get(http::subagents_list))
.route("/api/subagents/toggle", routing::post(http::subagents_toggle)) .route(
.route("/api/subagents/update", routing::put(http::subagents_update)) "/api/subagents/toggle",
routing::post(http::subagents_toggle),
)
.route(
"/api/subagents/update",
routing::put(http::subagents_update),
)
.route("/api/experts", routing::get(http::experts_list)) .route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle)) .route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create)) .route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update)) .route("/api/experts/update", routing::put(http::experts_update))
.route("/api/experts/delete", routing::delete(http::experts_delete)) .route("/api/experts/delete", routing::delete(http::experts_delete))
.route("/api/experts/selected", routing::get(http::experts_selected)) .route(
"/api/experts/selected",
routing::get(http::experts_selected),
)
.route("/api/experts/select", routing::post(http::experts_select)) .route("/api/experts/select", routing::post(http::experts_select))
.route("/api/session/select-model", routing::post(http::session_select_model)) .route(
.route("/api/session/selected-model", routing::get(http::session_selected_model)) "/api/session/select-model",
routing::post(http::session_select_model),
)
.route(
"/api/session/selected-model",
routing::get(http::session_selected_model),
)
.route("/ws", routing::get(ws::ws_handler)) .route("/ws", routing::get(ws::ws_handler))
.fallback(static_handler) .fallback(static_handler)
.with_state(state.clone()) .with_state(state.clone())
@ -267,7 +296,10 @@ pub async fn run(
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string()); let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string());
Router::new() Router::new()
.route("/health", routing::get(http::health)) .route("/health", routing::get(http::health))
.route("/api/config", routing::get(http::get_config).put(http::save_config)) .route(
"/api/config",
routing::get(http::get_config).put(http::save_config),
)
.route("/api/restart", routing::post(http::restart)) .route("/api/restart", routing::post(http::restart))
.route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list)) .route("/api/skills", routing::get(http::skills_list))
@ -275,17 +307,32 @@ pub async fn run(
.route("/api/tools", routing::get(http::tools_list)) .route("/api/tools", routing::get(http::tools_list))
.route("/api/model-options", routing::get(http::model_options)) .route("/api/model-options", routing::get(http::model_options))
.route("/api/subagents", routing::get(http::subagents_list)) .route("/api/subagents", routing::get(http::subagents_list))
.route("/api/subagents/toggle", routing::post(http::subagents_toggle)) .route(
.route("/api/subagents/update", routing::put(http::subagents_update)) "/api/subagents/toggle",
routing::post(http::subagents_toggle),
)
.route(
"/api/subagents/update",
routing::put(http::subagents_update),
)
.route("/api/experts", routing::get(http::experts_list)) .route("/api/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle)) .route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create)) .route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update)) .route("/api/experts/update", routing::put(http::experts_update))
.route("/api/experts/delete", routing::delete(http::experts_delete)) .route("/api/experts/delete", routing::delete(http::experts_delete))
.route("/api/experts/selected", routing::get(http::experts_selected)) .route(
"/api/experts/selected",
routing::get(http::experts_selected),
)
.route("/api/experts/select", routing::post(http::experts_select)) .route("/api/experts/select", routing::post(http::experts_select))
.route("/api/session/select-model", routing::post(http::session_select_model)) .route(
.route("/api/session/selected-model", routing::get(http::session_selected_model)) "/api/session/select-model",
routing::post(http::session_select_model),
)
.route(
"/api/session/selected-model",
routing::get(http::session_selected_model),
)
.route("/ws", routing::get(ws::ws_handler)) .route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir)) .fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone()) .with_state(state.clone())

View File

@ -16,12 +16,7 @@ impl ModelSelectionStore {
} }
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。 /// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
pub fn set( pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
&self,
session_id: &str,
provider: Option<String>,
model: Option<String>,
) {
let mut selections = self let mut selections = self
.selections .selections
.write() .write()
@ -76,9 +71,6 @@ mod tests {
fn set_only_provider_keeps_entry() { fn set_only_provider_keeps_entry() {
let store = ModelSelectionStore::new(); let store = ModelSelectionStore::new();
store.set("s1", Some("p1".to_string()), None); store.set("s1", Some("p1".to_string()), None);
assert_eq!( assert_eq!(store.get("s1"), Some((Some("p1".to_string()), None)));
store.get("s1"),
Some((Some("p1".to_string()), None))
);
} }
} }

View File

@ -22,7 +22,7 @@ use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::gateway::agent_factory::build_system_prompt_provider; use crate::gateway::agent_factory::build_system_prompt_provider;
use crate::gateway::cancel_manager::CancelManager; use crate::gateway::cancel_manager::CancelManager;
use crate::providers::{create_provider, ProviderRuntimeConfig}; use crate::providers::{ProviderRuntimeConfig, create_provider};
use crate::storage::persistent_session_id; use crate::storage::persistent_session_id;
use crate::topic_description::generate_topic_description; use crate::topic_description::generate_topic_description;
@ -52,8 +52,8 @@ impl InboundProcessor {
let store = session_manager.store(); let store = session_manager.store();
// 注册 Session 处理器 // 注册 Session 处理器
let session_handler = SessionCommandHandler::new(store.clone()) let session_handler =
.with_session_manager(session_manager.clone()); SessionCommandHandler::new(store.clone()).with_session_manager(session_manager.clone());
command_router.register(Box::new(session_handler)); command_router.register(Box::new(session_handler));
// 注册 list_sessions 处理器 // 注册 list_sessions 处理器
@ -79,7 +79,7 @@ impl InboundProcessor {
// 注册 get_current 处理器 // 注册 get_current 处理器
command_router.register(Box::new( command_router.register(Box::new(
GetCurrentSessionCommandHandler::new(store.clone()) GetCurrentSessionCommandHandler::new(store.clone())
.with_system_prompt_provider(system_prompt_provider.clone()) .with_system_prompt_provider(system_prompt_provider.clone()),
)); ));
// 注册 load_topic 处理器 // 注册 load_topic 处理器
@ -185,7 +185,8 @@ impl InboundProcessor {
let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id); let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id);
// 获取当前话题(封装了 session 创建逻辑) // 获取当前话题(封装了 session 创建逻辑)
let current_topic = self.session_manager let current_topic = self
.session_manager
.get_current_topic(&inbound.channel, &inbound.chat_id) .get_current_topic(&inbound.channel, &inbound.chat_id)
.await?; .await?;
@ -196,7 +197,8 @@ impl InboundProcessor {
if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) { if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) {
// 使用命令路由器处理 // 使用命令路由器处理
let mut cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel) let mut cmd_ctx =
crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
.with_session_id(&session_id) .with_session_id(&session_id)
.with_chat_id(&inbound.chat_id); .with_chat_id(&inbound.chat_id);
// 只在有话题时才设置 topic_id // 只在有话题时才设置 topic_id
@ -204,7 +206,10 @@ impl InboundProcessor {
cmd_ctx = cmd_ctx.with_topic_id(topic_id.as_str()); cmd_ctx = cmd_ctx.with_topic_id(topic_id.as_str());
} }
let response = self.command_router.dispatch_with_response(cmd, cmd_ctx).await; let response = self
.command_router
.dispatch_with_response(cmd, cmd_ctx)
.await;
// 发送响应给用户 // 发送响应给用户
if response.success { if response.success {
@ -295,7 +300,9 @@ impl InboundProcessor {
outbound.metadata.extend(inbound.forwarded_metadata.clone()); outbound.metadata.extend(inbound.forwarded_metadata.clone());
// 注入 topic_id 到 outbound metadata用于前端按话题隔离消息 // 注入 topic_id 到 outbound metadata用于前端按话题隔离消息
if let Some(ref topic_id) = current_topic { if let Some(ref topic_id) = current_topic {
outbound.metadata.insert("topic_id".to_string(), topic_id.clone()); outbound
.metadata
.insert("topic_id".to_string(), topic_id.clone());
} }
if let Err(error) = self.bus.publish_outbound(outbound).await { if let Err(error) = self.bus.publish_outbound(outbound).await {
tracing::error!(error = %error, "Failed to publish outbound"); tracing::error!(error = %error, "Failed to publish outbound");
@ -306,10 +313,17 @@ impl InboundProcessor {
if let Some(ref topic_id) = current_topic { if let Some(ref topic_id) = current_topic {
let store = self.session_manager.store(); let store = self.session_manager.store();
if let Ok(Some(topic)) = store.get_topic(topic_id) { if let Ok(Some(topic)) = store.get_topic(topic_id) {
if topic.description.is_none() || topic.description.as_ref().map(|d| d.is_empty()).unwrap_or(true) { if topic.description.is_none()
|| topic
.description
.as_ref()
.map(|d| d.is_empty())
.unwrap_or(true)
{
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成 // 检查并设置"生成中"守卫,防止竞态条件导致重复生成
let should_generate = { let should_generate = {
let mut in_flight = self.description_generation_in_flight.lock().unwrap(); let mut in_flight =
self.description_generation_in_flight.lock().unwrap();
if in_flight.contains(topic_id) { if in_flight.contains(topic_id) {
false false
} else { } else {
@ -329,7 +343,9 @@ impl InboundProcessor {
let first_user_message = store_clone let first_user_message = store_clone
.load_messages_for_topic(&topic_id_clone, None) .load_messages_for_topic(&topic_id_clone, None)
.ok() .ok()
.and_then(|msgs| msgs.into_iter().find(|m| m.role == "user")) .and_then(|msgs| {
msgs.into_iter().find(|m| m.role == "user")
})
.map(|m| m.content); .map(|m| m.content);
let message_content = match first_user_message { let message_content = match first_user_message {
@ -341,11 +357,22 @@ impl InboundProcessor {
} }
}; };
let runtime_config: ProviderRuntimeConfig = provider_config.into(); let runtime_config: ProviderRuntimeConfig =
provider_config.into();
if let Ok(provider) = create_provider(runtime_config) { if let Ok(provider) = create_provider(runtime_config) {
match generate_topic_description(provider.as_ref(), &message_content).await { match generate_topic_description(
provider.as_ref(),
&message_content,
)
.await
{
Ok(description) => { Ok(description) => {
if let Err(e) = store_clone.update_topic_description(&topic_id_clone, &description) { if let Err(e) = store_clone
.update_topic_description(
&topic_id_clone,
&description,
)
{
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description"); tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description");
} else { } else {
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated"); tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");

View File

@ -57,8 +57,9 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result<Option<String>,
ensure_parent_dir(path)?; ensure_parent_dir(path)?;
// 文件不存在时创建空白模板 // 文件不存在时创建空白模板
if !path.exists() { if !path.exists() {
fs::write(path, template) fs::write(path, template).map_err(|err| {
.map_err(|err| AgentError::Other(format!("create AGENT.md template error: {}", err)))?; AgentError::Other(format!("create AGENT.md template error: {}", err))
})?;
} }
// 读取内容,仅当非空(去除注释后)时注入 // 读取内容,仅当非空(去除注释后)时注入
let content = fs::read_to_string(path) let content = fs::read_to_string(path)
@ -70,8 +71,9 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result<Option<String>,
} }
PromptSource::AutoGenerated(path) => { PromptSource::AutoGenerated(path) => {
if path.exists() { if path.exists() {
let content = fs::read_to_string(path) let content = fs::read_to_string(path).map_err(|err| {
.map_err(|err| AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err)))?; AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err))
})?;
let without_comments = strip_comments_and_whitespace(&content); let without_comments = strip_comments_and_whitespace(&content);
if !without_comments.is_empty() { if !without_comments.is_empty() {
fragments.push(without_comments); fragments.push(without_comments);
@ -337,6 +339,9 @@ mod tests {
persist_memory_summary(&memory_path, "\n## 用户记忆摘要\n- 偏好简洁\n\n").unwrap(); persist_memory_summary(&memory_path, "\n## 用户记忆摘要\n- 偏好简洁\n\n").unwrap();
assert_eq!(fs::read_to_string(&memory_path).unwrap(), "## 用户记忆摘要\n- 偏好简洁\n"); assert_eq!(
fs::read_to_string(&memory_path).unwrap(),
"## 用户记忆摘要\n- 偏好简洁\n"
);
} }
} }

View File

@ -8,7 +8,9 @@ use tokio::sync::RwLock;
use crate::agent::AgentError; use crate::agent::AgentError;
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig}; use crate::config::{
LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig,
};
use crate::gateway::model_selection::ModelSelectionStore; use crate::gateway::model_selection::ModelSelectionStore;
use crate::gateway::tool_registry_factory::ToolRegistryFactory; use crate::gateway::tool_registry_factory::ToolRegistryFactory;
use crate::mcp::McpInitializer; use crate::mcp::McpInitializer;
@ -18,13 +20,13 @@ use crate::storage::{
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository, ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
SessionStore, SkillEventRepository, TodoRepository, SessionStore, SkillEventRepository, TodoRepository,
}; };
use crate::tools::task::runtime::SubagentRuntime;
use crate::tools::{
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender,
SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
};
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime;
use crate::tools::todo_write::TodoItem; use crate::tools::todo_write::TodoItem;
use crate::tools::{
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, SessionMessageSender,
SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
};
use super::agent_factory::AgentFactory; use super::agent_factory::AgentFactory;
use super::cli_session::CliSessionService; use super::cli_session::CliSessionService;
@ -55,7 +57,16 @@ pub(crate) fn build_session_manager(
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
model_resolver: Arc<ModelResolver>, model_resolver: Arc<ModelResolver>,
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> { ) -> Result<
(
SessionManager,
Arc<dyn TaskRepository>,
Option<Arc<McpClientManager>>,
Arc<SubagentRuntime>,
Arc<ModelSelectionStore>,
),
AgentError,
> {
build_session_manager_with_sender( build_session_manager_with_sender(
agent_prompt_reinject_every, agent_prompt_reinject_every,
show_tool_results, show_tool_results,
@ -94,7 +105,16 @@ pub(crate) fn build_session_manager_with_sender(
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
model_resolver: Arc<ModelResolver>, model_resolver: Arc<ModelResolver>,
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> { ) -> Result<
(
SessionManager,
Arc<dyn TaskRepository>,
Option<Arc<McpClientManager>>,
Arc<SubagentRuntime>,
Arc<ModelSelectionStore>,
),
AgentError,
> {
let store = Arc::new( let store = Arc::new(
SessionStore::new() SessionStore::new()
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?, .map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
@ -181,18 +201,20 @@ pub(crate) fn build_session_manager_with_sender(
} }
// Create SubAgentRuntime (if task tool is enabled) // Create SubAgentRuntime (if task tool is enabled)
let (factory, task_repository, subagent_runtime): (_, Arc<dyn TaskRepository>, Arc<SubagentRuntime>) = if task_config.enabled { let (factory, task_repository, subagent_runtime): (
_,
Arc<dyn TaskRepository>,
Arc<SubagentRuntime>,
) = if task_config.enabled {
let task_repository = Arc::new(InMemoryTaskRepository::new()); let task_repository = Arc::new(InMemoryTaskRepository::new());
// Build subagent tools with MCP tools (task tool registered separately below) // Build subagent tools with MCP tools (task tool registered separately below)
let subagent_tools = Arc::new( let subagent_tools = Arc::new(factory.build_subagent_tools(
factory.build_subagent_tools(
if mcp_tools_for_subagents.is_empty() { if mcp_tools_for_subagents.is_empty() {
None None
} else { } else {
Some(mcp_tools_for_subagents.clone()) Some(mcp_tools_for_subagents.clone())
} },
) ));
);
// Create subagent catalog with discovery, wrap in SubagentRuntime // Create subagent catalog with discovery, wrap in SubagentRuntime
let catalog = SubagentCatalog::discover(&subagents_config); let catalog = SubagentCatalog::discover(&subagents_config);
@ -230,11 +252,19 @@ pub(crate) fn build_session_manager_with_sender(
)); ));
} }
(factory.with_subagent_runtime(default_subagent_runtime), task_repository, subagent_runtime) (
factory.with_subagent_runtime(default_subagent_runtime),
task_repository,
subagent_runtime,
)
} else { } else {
// task_config 未启用时仍创建 subagent_runtime供 API 使用) // task_config 未启用时仍创建 subagent_runtime供 API 使用)
let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone())); let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone()));
(factory, Arc::new(InMemoryTaskRepository::new()), subagent_runtime) (
factory,
Arc::new(InMemoryTaskRepository::new()),
subagent_runtime,
)
}; };
// Build base tools // Build base tools
@ -306,7 +336,8 @@ pub(crate) fn build_session_manager_with_sender(
// Extract MCP manager for lifecycle management (e.g., disconnect on restart) // Extract MCP manager for lifecycle management (e.g., disconnect on restart)
let mcp_manager = mcp_initializer.manager(); let mcp_manager = mcp_initializer.manager();
Ok((SessionManager::from_services(SessionManagerServices { Ok((
SessionManager::from_services(SessionManagerServices {
tools: tools as Arc<ToolRegistry>, tools: tools as Arc<ToolRegistry>,
skills, skills,
experts, experts,
@ -319,5 +350,10 @@ pub(crate) fn build_session_manager_with_sender(
scheduled_tasks, scheduled_tasks,
memory_maintenance, memory_maintenance,
task_repository: task_repository.clone(), task_repository: task_repository.clone(),
}), task_repository, mcp_manager, subagent_runtime, model_selections)) }),
task_repository,
mcp_manager,
subagent_runtime,
model_selections,
))
} }

View File

@ -37,7 +37,10 @@ impl ScheduledAgentTaskService {
// 根据 chat_id 自动选择 Session // 根据 chat_id 自动选择 Session
// - scheduler/ 开头:使用定时任务专用 Session独立实例不与用户消息竞争锁 // - scheduler/ 开头:使用定时任务专用 Session独立实例不与用户消息竞争锁
// - 其他:使用主 Session // - 其他:使用主 Session
let session = self.lifecycle.active_session_for_chat_id(channel_name, chat_id).await?; let session = self
.lifecycle
.active_session_for_chat_id(channel_name, chat_id)
.await?;
let sender_id = options let sender_id = options
.sender_id .sender_id
.clone() .clone()

View File

@ -2,12 +2,15 @@ use crate::agent::{AgentError, AgentLoop, ContextCompressor, EmittedMessageHandl
#[cfg(test)] #[cfg(test)]
use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT; use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT;
use crate::bus::{ChatMessage, MessageBus, OutboundMessage}; use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
use crate::providers::StreamDelta;
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::protocol::WsOutbound; use crate::protocol::WsOutbound;
use crate::providers::StreamDelta;
use crate::scheduler::ScheduledAgentTaskOptions; use crate::scheduler::ScheduledAgentTaskOptions;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository}; use crate::storage::{
ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore,
SkillEventRepository,
};
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime; use crate::tools::task::runtime::SubagentRuntime;
@ -24,8 +27,7 @@ use super::execution::should_display_message_to_user;
#[cfg(test)] #[cfg(test)]
use super::memory_maintenance::{ use super::memory_maintenance::{
MemoryMaintenanceMerge, apply_memory_maintenance_output, build_memory_maintenance_plan, MemoryMaintenanceMerge, apply_memory_maintenance_output, build_memory_maintenance_plan,
extract_json_object, is_recoverable_maintenance_llm_error, extract_json_object, is_recoverable_maintenance_llm_error, strip_json_code_fence,
strip_json_code_fence,
}; };
use super::memory_maintenance::{MemoryMaintenanceScopeResult, MemoryOrganizationOutput}; use super::memory_maintenance::{MemoryMaintenanceScopeResult, MemoryOrganizationOutput};
use super::memory_maintenance_coordinator::MemoryMaintenanceCoordinator; use super::memory_maintenance_coordinator::MemoryMaintenanceCoordinator;
@ -125,7 +127,9 @@ impl EmittedMessageHandler for BusToolCallEmitter {
// Get or create the stream message ID // Get or create the stream message ID
let message_id = { let message_id = {
let mut guard = self.stream_message_id.lock().unwrap(); let mut guard = self.stream_message_id.lock().unwrap();
guard.get_or_insert_with(|| Uuid::new_v4().to_string()).clone() guard
.get_or_insert_with(|| Uuid::new_v4().to_string())
.clone()
}; };
// Empty content + no reasoning = stream end signal // Empty content + no reasoning = stream end signal
@ -180,7 +184,11 @@ impl BusToolCallEmitter {
.cloned() .cloned()
.unwrap_or_else(|| session_id.clone()); .unwrap_or_else(|| session_id.clone());
let topic_id = self.metadata.get("topic_id").filter(|t| !t.is_empty()).cloned(); let topic_id = self
.metadata
.get("topic_id")
.filter(|t| !t.is_empty())
.cloned();
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@ -304,11 +312,7 @@ impl Session {
skills, skills,
agent_factory, agent_factory,
compressor: ContextCompressor::from_provider_config(&provider_config), compressor: ContextCompressor::from_provider_config(&provider_config),
history: SessionHistory::new( history: SessionHistory::new(channel_name, conversations, skill_events),
channel_name,
conversations,
skill_events,
),
store, store,
pending_cancel_tokens: HashMap::new(), pending_cancel_tokens: HashMap::new(),
}) })
@ -611,7 +615,9 @@ impl Session {
// 消费 pending 的取消信号接收端(如果存在) // 消费 pending 的取消信号接收端(如果存在)
// 优先按 topic_id 查找;无 topic 时回退 chat_id // 优先按 topic_id 查找;无 topic 时回退 chat_id
let cancel_token = match &topic_id { let cancel_token = match &topic_id {
Some(tid) => self.pending_cancel_tokens.remove(tid) Some(tid) => self
.pending_cancel_tokens
.remove(tid)
.or_else(|| self.pending_cancel_tokens.remove(session_chat_id)), .or_else(|| self.pending_cancel_tokens.remove(session_chat_id)),
None => self.pending_cancel_tokens.remove(session_chat_id), None => self.pending_cancel_tokens.remove(session_chat_id),
}; };
@ -780,7 +786,11 @@ impl SessionManager {
} }
/// 获取指定 chat 的当前话题(确保 session 存在,自动从数据库恢复) /// 获取指定 chat 的当前话题(确保 session 存在,自动从数据库恢复)
pub async fn get_current_topic(&self, channel_name: &str, chat_id: &str) -> Result<Option<String>, AgentError> { pub async fn get_current_topic(
&self,
channel_name: &str,
chat_id: &str,
) -> Result<Option<String>, AgentError> {
self.ensure_session(channel_name).await?; self.ensure_session(channel_name).await?;
if let Some(session) = self.get(channel_name).await { if let Some(session) = self.get(channel_name).await {
let mut guard = session.lock().await; let mut guard = session.lock().await;
@ -788,7 +798,9 @@ impl SessionManager {
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题 // 如果内存中没有当前话题,从数据库恢复最近活跃的话题
if guard.current_topic(chat_id).is_none() { if guard.current_topic(chat_id).is_none() {
let session_id = guard.persistent_session_id(chat_id); let session_id = guard.persistent_session_id(chat_id);
let topics = self.store.list_topics(&session_id) let topics = self
.store
.list_topics(&session_id)
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?; .map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
if let Some(latest_topic) = topics.first() { if let Some(latest_topic) = topics.first() {
@ -802,10 +814,7 @@ impl SessionManager {
); );
} else { } else {
// 数据库中也没有话题,自动创建默认话题 // 数据库中也没有话题,自动创建默认话题
let title = format!( let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M"));
"话题 {}",
chrono::Local::now().format("%m/%d %H:%M")
);
match self.store.create_topic(&session_id, &title, None) { match self.store.create_topic(&session_id, &title, None) {
Ok(topic) => { Ok(topic) => {
guard.set_current_topic(chat_id, Some(topic.id.clone())); guard.set_current_topic(chat_id, Some(topic.id.clone()));
@ -845,7 +854,10 @@ impl SessionManager {
token: tokio::sync::watch::Receiver<()>, token: tokio::sync::watch::Receiver<()>,
) { ) {
if let Some(session) = self.get(channel_name).await { if let Some(session) = self.get(channel_name).await {
session.lock().await.set_cancel_receiver(chat_id, topic_id, token); session
.lock()
.await
.set_cancel_receiver(chat_id, topic_id, token);
} }
} }
@ -904,7 +916,13 @@ impl SessionManager {
options: ScheduledAgentTaskOptions, options: ScheduledAgentTaskOptions,
) -> Result<Vec<OutboundMessage>, AgentError> { ) -> Result<Vec<OutboundMessage>, AgentError> {
self.scheduled_tasks self.scheduled_tasks
.run(channel_name, session_chat_id, notification_chat_id, prompt, options) .run(
channel_name,
session_chat_id,
notification_chat_id,
prompt,
options,
)
.await .await
} }
@ -1000,12 +1018,16 @@ mod tests {
let first = session.create_user_message("first", Vec::new()); let first = session.create_user_message("first", Vec::new());
let first_id = first.id.clone(); let first_id = first.id.clone();
session.append_persisted_message("chat-1", Some(&topic_id), first).unwrap(); session
.append_persisted_message("chat-1", Some(&topic_id), first)
.unwrap();
assert!(session.is_latest_user_message(&topic_id, &first_id)); assert!(session.is_latest_user_message(&topic_id, &first_id));
let second = session.create_user_message("second", Vec::new()); let second = session.create_user_message("second", Vec::new());
let second_id = second.id.clone(); let second_id = second.id.clone();
session.append_persisted_message("chat-1", Some(&topic_id), second).unwrap(); session
.append_persisted_message("chat-1", Some(&topic_id), second)
.unwrap();
assert!(!session.is_latest_user_message(&topic_id, &first_id)); assert!(!session.is_latest_user_message(&topic_id, &first_id));
assert!(session.is_latest_user_message(&topic_id, &second_id)); assert!(session.is_latest_user_message(&topic_id, &second_id));
@ -1052,9 +1074,15 @@ mod tests {
let first = session.create_user_message("first", Vec::new()); let first = session.create_user_message("first", Vec::new());
let first_id = first.id.clone(); let first_id = first.id.clone();
session.append_persisted_message("chat-1", Some(&topic_id), first).unwrap();
session session
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::assistant("answer-1")) .append_persisted_message("chat-1", Some(&topic_id), first)
.unwrap();
session
.append_persisted_message(
"chat-1",
Some(&topic_id),
ChatMessage::assistant("answer-1"),
)
.unwrap(); .unwrap();
let second = session.create_user_message("second", Vec::new()); let second = session.create_user_message("second", Vec::new());
@ -1062,7 +1090,11 @@ mod tests {
.append_persisted_message("chat-1", Some(&topic_id), second.clone()) .append_persisted_message("chat-1", Some(&topic_id), second.clone())
.unwrap(); .unwrap();
session session
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::assistant("answer-2")) .append_persisted_message(
"chat-1",
Some(&topic_id),
ChatMessage::assistant("answer-2"),
)
.unwrap(); .unwrap();
let preserved_messages = session.get_history(&topic_id).unwrap().clone(); let preserved_messages = session.get_history(&topic_id).unwrap().clone();
@ -1235,7 +1267,15 @@ mod tests {
.unwrap(); .unwrap();
let outbound = session_manager let outbound = session_manager
.handle_message("test-channel", "user-1", "chat-1", "hello", Vec::new(), None, None) .handle_message(
"test-channel",
"user-1",
"chat-1",
"hello",
Vec::new(),
None,
None,
)
.await .await
.unwrap(); .unwrap();
@ -1733,7 +1773,8 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_run_memory_maintenance_for_all_scopes_scans_all_scopes_even_without_recent_updates() { async fn test_run_memory_maintenance_for_all_scopes_scans_all_scopes_even_without_recent_updates()
{
let mock_response_content = serde_json::to_string(&json!({ let mock_response_content = serde_json::to_string(&json!({
"user_facts": ["用户在做AI产品"], "user_facts": ["用户在做AI产品"],
"preferences": [], "preferences": [],
@ -1983,11 +2024,18 @@ mod tests {
let all_memories = store.list_memories_for_scope("user", scope_key).unwrap(); let all_memories = store.list_memories_for_scope("user", scope_key).unwrap();
// 过滤掉 _meta 记录 // 过滤掉 _meta 记录
let user_memories: Vec<_> = all_memories.iter().filter(|m| m.namespace != "_meta").collect(); let user_memories: Vec<_> = all_memories
.iter()
.filter(|m| m.namespace != "_meta")
.collect();
// 合并 2 条为 1 条,删除 1 条7 - 2 + 1 = 5 条 // 合并 2 条为 1 条,删除 1 条7 - 2 + 1 = 5 条
assert_eq!(user_memories.len(), 5); assert_eq!(user_memories.len(), 5);
// 验证合并后的记忆存在 // 验证合并后的记忆存在
assert!(user_memories.iter().any(|m| m.namespace == "user" && m.memory_key == "work")); assert!(
user_memories
.iter()
.any(|m| m.namespace == "user" && m.memory_key == "work")
);
} }
#[test] #[test]
@ -2010,19 +2058,16 @@ mod tests {
let store = Arc::new(SessionStore::in_memory().unwrap()); let store = Arc::new(SessionStore::in_memory().unwrap());
let bus = MessageBus::new(4); let bus = MessageBus::new(4);
let emitter = let emitter =
BusToolCallEmitter::new( BusToolCallEmitter::new(bus.clone(), "test-channel", "chat-1", HashMap::new(), store);
bus.clone(),
"test-channel",
"chat-1",
HashMap::new(),
store,
);
emitter emitter
.handle(ChatMessage::tool("call-1", "calculator", "2")) .handle(ChatMessage::tool("call-1", "calculator", "2"))
.await; .await;
let msg = tokio::time::timeout(std::time::Duration::from_millis(500), bus.consume_outbound()) let msg = tokio::time::timeout(
std::time::Duration::from_millis(500),
bus.consume_outbound(),
)
.await .await
.expect("timeout waiting for outbound message") .expect("timeout waiting for outbound message")
.expect("bus outbound closed"); .expect("bus outbound closed");
@ -2111,7 +2156,11 @@ mod tests {
for turn in 0..100 { for turn in 0..100 {
session session
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::user(format!("user-{turn}"))) .append_persisted_message(
"chat-1",
Some(&topic_id),
ChatMessage::user(format!("user-{turn}")),
)
.unwrap(); .unwrap();
} }
@ -2188,7 +2237,11 @@ mod tests {
for turn in 0..100 { for turn in 0..100 {
session session
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::user(format!("user-{turn}"))) .append_persisted_message(
"chat-1",
Some(&topic_id),
ChatMessage::user(format!("user-{turn}")),
)
.unwrap(); .unwrap();
} }

View File

@ -115,7 +115,9 @@ impl SessionHistory {
} }
pub(crate) fn get_or_create_history(&mut self, topic_id: &str) -> &mut Vec<ChatMessage> { pub(crate) fn get_or_create_history(&mut self, topic_id: &str) -> &mut Vec<ChatMessage> {
self.topic_histories.entry(topic_id.to_string()).or_default() self.topic_histories
.entry(topic_id.to_string())
.or_default()
} }
pub(crate) fn get_history(&self, topic_id: &str) -> Option<&Vec<ChatMessage>> { pub(crate) fn get_history(&self, topic_id: &str) -> Option<&Vec<ChatMessage>> {

View File

@ -52,9 +52,12 @@ impl SessionLifecycleService {
channel_name: &str, channel_name: &str,
chat_id: &str, chat_id: &str,
) -> Result<Arc<Mutex<Session>>, AgentError> { ) -> Result<Arc<Mutex<Session>>, AgentError> {
self.session_pool.ensure_session_for_chat_id(channel_name, chat_id).await?; self.session_pool
.ensure_session_for_chat_id(channel_name, chat_id)
.await?;
self.touch(channel_name).await; self.touch(channel_name).await;
self.session_pool.get_for_chat_id(channel_name, chat_id) self.session_pool
.get_for_chat_id(channel_name, chat_id)
.await .await
.ok_or_else(|| AgentError::Other("Session not found".to_string())) .ok_or_else(|| AgentError::Other("Session not found".to_string()))
} }

View File

@ -122,7 +122,7 @@ mod tests {
// 使用临时目录确保跨平台兼容 // 使用临时目录确保跨平台兼容
attachments: vec![MediaItem::new( 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" "image",
)], )],
}, },
) )

View File

@ -49,7 +49,10 @@ impl SessionPool {
} }
/// 确保定时任务专用 Session 存在 /// 确保定时任务专用 Session 存在
pub(crate) async fn ensure_scheduler_session(&self, channel_name: &str) -> Result<(), AgentError> { pub(crate) async fn ensure_scheduler_session(
&self,
channel_name: &str,
) -> Result<(), AgentError> {
self.ensure_session_internal(channel_name, true).await self.ensure_session_internal(channel_name, true).await
} }
@ -59,7 +62,11 @@ impl SessionPool {
/// session 创建含配置加载、agent 工厂构造),再次持锁插入并处理竞态。 /// session 创建含配置加载、agent 工厂构造),再次持锁插入并处理竞态。
/// 避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的 /// 避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的
/// session 访问串行化。 /// session 访问串行化。
async fn ensure_session_internal(&self, channel_name: &str, is_scheduler: bool) -> Result<(), AgentError> { async fn ensure_session_internal(
&self,
channel_name: &str,
is_scheduler: bool,
) -> Result<(), AgentError> {
// Fast path: 已存在直接返回(短暂持锁) // Fast path: 已存在直接返回(短暂持锁)
{ {
let inner = self.inner.lock().await; let inner = self.inner.lock().await;
@ -109,14 +116,26 @@ impl SessionPool {
} }
/// 获取定时任务专用 Session /// 获取定时任务专用 Session
pub(crate) async fn get_scheduler_session(&self, channel_name: &str) -> Option<Arc<Mutex<Session>>> { pub(crate) async fn get_scheduler_session(
self.inner.lock().await.scheduler_sessions.get(channel_name).cloned() &self,
channel_name: &str,
) -> Option<Arc<Mutex<Session>>> {
self.inner
.lock()
.await
.scheduler_sessions
.get(channel_name)
.cloned()
} }
/// 根据 chat_id 自动选择 Session /// 根据 chat_id 自动选择 Session
/// - scheduler/ 开头:返回定时任务专用 Session /// - scheduler/ 开头:返回定时任务专用 Session
/// - 其他:返回主 Session /// - 其他:返回主 Session
pub(crate) async fn get_for_chat_id(&self, channel_name: &str, chat_id: &str) -> Option<Arc<Mutex<Session>>> { pub(crate) async fn get_for_chat_id(
&self,
channel_name: &str,
chat_id: &str,
) -> Option<Arc<Mutex<Session>>> {
if is_scheduler_chat_id(chat_id) { if is_scheduler_chat_id(chat_id) {
self.get_scheduler_session(channel_name).await self.get_scheduler_session(channel_name).await
} else { } else {
@ -125,7 +144,11 @@ impl SessionPool {
} }
/// 确保 Session 存在(根据 chat_id 自动选择) /// 确保 Session 存在(根据 chat_id 自动选择)
pub(crate) async fn ensure_session_for_chat_id(&self, channel_name: &str, chat_id: &str) -> Result<(), AgentError> { pub(crate) async fn ensure_session_for_chat_id(
&self,
channel_name: &str,
chat_id: &str,
) -> Result<(), AgentError> {
if is_scheduler_chat_id(chat_id) { if is_scheduler_chat_id(chat_id) {
self.ensure_scheduler_session(channel_name).await self.ensure_scheduler_session(channel_name).await
} else { } else {

View File

@ -1,6 +1,6 @@
use axum::{ use axum::{
body::Body, body::Body,
http::{header, Response, StatusCode, Uri}, http::{Response, StatusCode, Uri, header},
}; };
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
@ -16,11 +16,7 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
let path = uri.path().trim_start_matches('/'); let path = uri.path().trim_start_matches('/');
// 处理根路径,返回 index.html // 处理根路径,返回 index.html
let path = if path.is_empty() { let path = if path.is_empty() { "index.html" } else { path };
"index.html"
} else {
path
};
match StaticAssets::get(path) { match StaticAssets::get(path) {
Some(content) => { Some(content) => {

View File

@ -6,13 +6,14 @@ use tokio::sync::RwLock;
use crate::config::TaskConfig; use crate::config::TaskConfig;
use crate::mcp::McpClientManager; use crate::mcp::McpClientManager;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::storage::{MemoryRepository, SchedulerJobRepository, SkillEventRepository, TodoRepository}; use crate::storage::{
MemoryRepository, SchedulerJobRepository, SkillEventRepository, TodoRepository,
};
use crate::tools::todo_write::TodoItem; use crate::tools::todo_write::TodoItem;
use crate::tools::{ use crate::tools::{
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, HttpRequestTool,
HttpRequestTool, MemoryManageTool, MemorySearchTool, MemoryManageTool, MemorySearchTool, SchedulerManageTool, SessionMessageSender, SessionSendTool,
SchedulerManageTool, SessionMessageSender, SessionSendTool, ShellSessionManager, ShellSessionManager, SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool, TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool,
}; };
@ -72,18 +73,12 @@ impl ToolRegistryFactory {
self self
} }
pub(crate) fn with_subagent_runtime( pub(crate) fn with_subagent_runtime(mut self, runtime: Arc<dyn SubAgentRuntime>) -> Self {
mut self,
runtime: Arc<dyn SubAgentRuntime>,
) -> Self {
self.subagent_runtime = Some(runtime); self.subagent_runtime = Some(runtime);
self self
} }
pub(crate) fn with_mcp_manager( pub(crate) fn with_mcp_manager(mut self, manager: Arc<McpClientManager>) -> Self {
mut self,
manager: Arc<McpClientManager>,
) -> Self {
self.mcp_manager = Some(manager); self.mcp_manager = Some(manager);
self self
} }
@ -118,8 +113,14 @@ impl ToolRegistryFactory {
} }
if self.is_enabled("todo_write") { if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state { if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone())); registry.register(TodoWriteTool::new(
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone())); state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoReadTool::new(
state.clone(),
self.todo_repository.clone(),
));
} }
} }
if self.is_enabled("session_send") { if self.is_enabled("session_send") {
@ -226,8 +227,14 @@ impl ToolRegistryFactory {
// Todo 追踪工具 // Todo 追踪工具
if self.is_enabled("todo_write") { if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state { if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone())); registry.register(TodoWriteTool::new(
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone())); state.clone(),
self.todo_repository.clone(),
));
registry.register(TodoReadTool::new(
state.clone(),
self.todo_repository.clone(),
));
} }
} }

View File

@ -10,16 +10,16 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
use crate::command::handlers::help::HelpCommandHandler; use crate::command::handlers::help::HelpCommandHandler;
use crate::command::handlers::list_channels::ListChannelsCommandHandler; use crate::command::handlers::list_channels::ListChannelsCommandHandler;
use crate::command::handlers::list_memories::ListMemoriesCommandHandler; use crate::command::handlers::list_memories::ListMemoriesCommandHandler;
use crate::command::handlers::list_skills::ListSkillsCommandHandler;
use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler; use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
use crate::command::handlers::list_todos::ListTodosCommandHandler;
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler; use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
use crate::command::handlers::list_skills::ListSkillsCommandHandler;
use crate::command::handlers::list_todos::ListTodosCommandHandler;
use crate::command::handlers::list_topics::ListTopicsCommandHandler; use crate::command::handlers::list_topics::ListTopicsCommandHandler;
use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler; use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler;
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler; use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
use crate::command::handlers::load_topic::LoadTopicCommandHandler; use crate::command::handlers::load_topic::LoadTopicCommandHandler;
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
use crate::command::handlers::rename_topic::RenameTopicCommandHandler; use crate::command::handlers::rename_topic::RenameTopicCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler;
use crate::command::handlers::save_topic::SaveTopicCommandHandler; use crate::command::handlers::save_topic::SaveTopicCommandHandler;
@ -27,7 +27,7 @@ use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::stop_execution::StopExecutionCommandHandler; use crate::command::handlers::stop_execution::StopExecutionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler; use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::gateway::agent_factory::build_system_prompt_provider; use crate::gateway::agent_factory::build_system_prompt_provider;
use crate::protocol::{WsInbound, WsOutbound, MediaSummary, parse_inbound, serialize_outbound}; use crate::protocol::{MediaSummary, WsInbound, WsOutbound, parse_inbound, serialize_outbound};
use crate::storage::persistent_session_id; use crate::storage::persistent_session_id;
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::tools::task::types::TaskSessionState; use crate::tools::task::types::TaskSessionState;
@ -68,7 +68,9 @@ fn build_media_filename(media_type: &str, file_name: Option<&str>) -> String {
/// Process attachments with base64 content: save to local file and return MediaItem with correct path /// Process attachments with base64 content: save to local file and return MediaItem with correct path
/// Keeps content_base64 for frontend display/download /// Keeps content_base64 for frontend display/download
fn process_attachments_with_base64(attachments: Vec<MediaSummary>) -> Result<Vec<MediaItem>, AgentError> { fn process_attachments_with_base64(
attachments: Vec<MediaSummary>,
) -> Result<Vec<MediaItem>, AgentError> {
if attachments.is_empty() { if attachments.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@ -82,15 +84,16 @@ fn process_attachments_with_base64(attachments: Vec<MediaSummary>) -> Result<Vec
.map(|att| { .map(|att| {
// If content_base64 exists, save to file and update path // If content_base64 exists, save to file and update path
if let Some(base64_content) = &att.content_base64 { if let Some(base64_content) = &att.content_base64 {
let decoded = STANDARD let decoded = STANDARD.decode(base64_content).map_err(|error| {
.decode(base64_content) AgentError::Other(format!("Failed to decode base64: {}", error))
.map_err(|error| AgentError::Other(format!("Failed to decode base64: {}", error)))?; })?;
let filename = build_media_filename(&att.media_type, att.file_name.as_deref()); let filename = build_media_filename(&att.media_type, att.file_name.as_deref());
let file_path = media_dir.join(&filename); let file_path = media_dir.join(&filename);
std::fs::write(&file_path, decoded) std::fs::write(&file_path, decoded).map_err(|error| {
.map_err(|error| AgentError::Other(format!("Failed to write media file: {}", error)))?; AgentError::Other(format!("Failed to write media file: {}", error))
})?;
tracing::info!( tracing::info!(
filename = %filename, filename = %filename,
@ -136,10 +139,8 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
let store = state.session_manager.store(); let store = state.session_manager.store();
// 1. 查询 websocket 和 cli 两个通道的 Sessions兼容旧版本 cli 通道创建的会话) // 1. 查询 websocket 和 cli 两个通道的 Sessions兼容旧版本 cli 通道创建的会话)
let mut websocket_sessions = store.list_sessions("websocket", false) let mut websocket_sessions = store.list_sessions("websocket", false).unwrap_or_default();
.unwrap_or_default(); let cli_channel_sessions = store.list_sessions("cli", false).unwrap_or_default();
let cli_channel_sessions = store.list_sessions("cli", false)
.unwrap_or_default();
websocket_sessions.extend(cli_channel_sessions); websocket_sessions.extend(cli_channel_sessions);
websocket_sessions.sort_by_key(|s| -(s.last_active_at)); websocket_sessions.sort_by_key(|s| -(s.last_active_at));
@ -180,9 +181,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
// 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道) // 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道)
let channels = state.channel_manager.build_channel_list().await; let channels = state.channel_manager.build_channel_list().await;
let _ = sender let _ = sender.send(WsOutbound::ChannelList { channels }).await;
.send(WsOutbound::ChannelList { channels })
.await;
// 3. 发送合并后的 Session 列表(已在上面合并了 websocket + cli 通道) // 3. 发送合并后的 Session 列表(已在上面合并了 websocket + cli 通道)
// 如果刚创建了新会话,确保它也在列表中 // 如果刚创建了新会话,确保它也在列表中
@ -304,7 +303,6 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
tracing::info!(session_id = %runtime_session_id, current_session_id = %current_session_id, "CLI session ended"); tracing::info!(session_id = %runtime_session_id, current_session_id = %current_session_id, "CLI session ended");
} }
async fn handle_inbound( async fn handle_inbound(
state: &Arc<GatewayState>, state: &Arc<GatewayState>,
sender: &mpsc::Sender<WsOutbound>, sender: &mpsc::Sender<WsOutbound>,
@ -394,7 +392,11 @@ async fn handle_inbound(
let store = state.session_manager.store(); let store = state.session_manager.store();
let skills = state.session_manager.skills(); let skills = state.session_manager.skills();
let skills_for_handler = skills.clone(); let skills_for_handler = skills.clone();
let provider_config = state.config.read().await.get_provider_config("default") let provider_config = state
.config
.read()
.await
.get_provider_config("default")
.map_err(|e| AgentError::Other(e.to_string()))?; .map_err(|e| AgentError::Other(e.to_string()))?;
let prompt_repository = state.session_manager.store().clone(); let prompt_repository = state.session_manager.store().clone();
@ -417,9 +419,13 @@ async fn handle_inbound(
// 注册 list_sessions 处理器 // 注册 list_sessions 处理器
router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 list_sessions_by_channel 处理器 // 注册 list_sessions_by_channel 处理器
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone()))); router.register(Box::new(ListSessionsByChannelCommandHandler::new(
store.clone(),
)));
// 注册 list_channels 处理器 // 注册 list_channels 处理器
router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(state.channel_manager.clone())))); router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(
state.channel_manager.clone(),
))));
// 注册 list_topics 处理器 // 注册 list_topics 处理器
router.register(Box::new(ListTopicsCommandHandler::new(store.clone()))); router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
// 注册 switch_topic 处理器 // 注册 switch_topic 处理器
@ -460,7 +466,9 @@ async fn handle_inbound(
let metadata = router.metadata_arc(); let metadata = router.metadata_arc();
router.register(Box::new(HelpCommandHandler::new(metadata))); router.register(Box::new(HelpCommandHandler::new(metadata)));
// 注册 list_scheduler_jobs 处理器 // 注册 list_scheduler_jobs 处理器
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone()))); router.register(Box::new(ListSchedulerJobsCommandHandler::new(
store.clone(),
)));
// 注册 list_memories 处理器 // 注册 list_memories 处理器
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone()))); router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
// 注册 list_skills 处理器 // 注册 list_skills 处理器
@ -524,51 +532,95 @@ async fn handle_inbound(
*current_topic_id = Some(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 { 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 topic history"); tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
} }
} }
// 加载子智能体任务消息 // 加载子智能体任务消息
if let Some(task_session_id) = response.metadata.get("task_session_id") { if let Some(task_session_id) = response.metadata.get("task_session_id") {
// 提前提取 task_id用于给历史消息打标记 // 提前提取 task_id用于给历史消息打标记
let task_id = response.metadata.get("task_id").cloned().unwrap_or_default(); let task_id = response
if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone()), Some(&state.task_repository)).await { .metadata
.get("task_id")
.cloned()
.unwrap_or_default();
if let Err(e) = send_task_messages(
&store,
task_session_id,
sender,
Some(task_id.clone()),
Some(&state.task_repository),
)
.await
{
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages"); tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
} }
// 发送 TaskMessagesLoaded 元数据 // 发送 TaskMessagesLoaded 元数据
let description = response.metadata.get("task_description").cloned().unwrap_or_default(); let description = response
let subagent_type = response.metadata.get("task_subagent_type").cloned().unwrap_or_default(); .metadata
let status = response.metadata.get("task_status").cloned().unwrap_or_default(); .get("task_description")
.cloned()
.unwrap_or_default();
let subagent_type = response
.metadata
.get("task_subagent_type")
.cloned()
.unwrap_or_default();
let status = response
.metadata
.get("task_status")
.cloned()
.unwrap_or_default();
let summary = response.metadata.get("task_summary").cloned(); let summary = response.metadata.get("task_summary").cloned();
let _ = sender.send(WsOutbound::TaskMessagesLoaded { let _ = sender
.send(WsOutbound::TaskMessagesLoaded {
task_id, task_id,
description, description,
subagent_type, subagent_type,
status, status,
summary, summary,
}).await; })
.await;
} }
// 处理定时任务列表 // 处理定时任务列表
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") { if let Some(jobs_json) = response.metadata.get("scheduler_jobs") {
if let Ok(jobs) = serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json) { if 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 Some(skills_json) = response.metadata.get("skills") {
if let Ok(skills) = serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json) { if 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 列表 // 处理 Todo 列表
if let Some(todos_json) = response.metadata.get("todos") { if let Some(todos_json) = response.metadata.get("todos") {
if let Ok(todos) = serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json) { if let Ok(todos) =
let scope_key = response.metadata.get("todos_scope_key").cloned().unwrap_or_default(); serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
{
let scope_key = response
.metadata
.get("todos_scope_key")
.cloned()
.unwrap_or_default();
tracing::info!(todo_count = todos.len(), %scope_key, "list_todos command response"); tracing::info!(todo_count = todos.len(), %scope_key, "list_todos command response");
let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await; let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await;
} }
@ -576,14 +628,18 @@ async fn handle_inbound(
// 处理记忆列表 // 处理记忆列表
if let Some(memories_json) = response.metadata.get("memories") { if let Some(memories_json) = response.metadata.get("memories") {
if let Ok(memories) = serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json) { if 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 后自动刷新列表 // 记忆 CRUD 后自动刷新列表
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") { if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") {
if let Ok(records) = store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) { if let Ok(records) =
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
{
let memories: Vec<crate::protocol::MemorySummary> = records let memories: Vec<crate::protocol::MemorySummary> = records
.into_iter() .into_iter()
.filter(|m| m.namespace != "_meta") .filter(|m| m.namespace != "_meta")
@ -602,15 +658,17 @@ async fn handle_inbound(
// 处理加载聊天消息请求 // 处理加载聊天消息请求
if let Some(load_chat_id) = response.metadata.get("load_chat_id") { if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
let load_chat_channel = response.metadata.get("load_chat_channel") let load_chat_channel = response
.metadata
.get("load_chat_channel")
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
// session_id = "{channel}:{chat_id}" (cli channel 例外) // session_id = "{channel}:{chat_id}" (cli channel 例外)
let session_id = crate::storage::persistent_session_id( let session_id =
&load_chat_channel, crate::storage::persistent_session_id(&load_chat_channel, load_chat_id);
load_chat_id, if let Err(e) =
); send_task_messages(&store, &session_id, sender, None, None).await
if let Err(e) = send_task_messages(&store, &session_id, sender, None, None).await { {
tracing::warn!( tracing::warn!(
error = %e, error = %e,
channel = %load_chat_channel, channel = %load_chat_channel,
@ -623,12 +681,22 @@ async fn handle_inbound(
if current_topic_id.is_none() { if current_topic_id.is_none() {
if let Some(topics_json) = response.metadata.get("topics") { if let Some(topics_json) = response.metadata.get("topics") {
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) { match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
topics_json,
) {
Ok(topics) => { Ok(topics) => {
if let Some(first_topic) = topics.first() { if let Some(first_topic) = topics.first() {
let topic_id = first_topic.topic_id.clone(); let topic_id = first_topic.topic_id.clone();
*current_topic_id = Some(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 { 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"); tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
} }
} }
@ -865,10 +933,15 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
let media_type = mime_type let media_type = mime_type
.as_ref() .as_ref()
.map(|m| { .map(|m| {
if m.starts_with("image/") { "image" } if m.starts_with("image/") {
else if m.starts_with("audio/") { "audio" } "image"
else if m.starts_with("video/") { "video" } } else if m.starts_with("audio/") {
else { "file" } "audio"
} else if m.starts_with("video/") {
"video"
} else {
"file"
}
}) })
.unwrap_or("file"); .unwrap_or("file");
@ -892,7 +965,8 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
"assistant" => { "assistant" => {
if let Some(tool_calls) = &msg.tool_calls { if let Some(tool_calls) = &msg.tool_calls {
let mut outbound = Vec::new(); let mut outbound = Vec::new();
let has_content_or_reasoning = !msg.content.trim().is_empty() || msg.reasoning_content.is_some(); let has_content_or_reasoning =
!msg.content.trim().is_empty() || msg.reasoning_content.is_some();
if has_content_or_reasoning { if has_content_or_reasoning {
outbound.push(WsOutbound::AssistantResponse { outbound.push(WsOutbound::AssistantResponse {
id: msg.id.clone(), id: msg.id.clone(),
@ -907,7 +981,11 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
}); });
} }
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复 // AssistantResponse 已携带 reasoning 时ToolCall 不再重复
let tc_reasoning = if has_content_or_reasoning { None } else { msg.reasoning_content.clone() }; let tc_reasoning = if has_content_or_reasoning {
None
} else {
msg.reasoning_content.clone()
};
for tool_call in tool_calls { for tool_call in tool_calls {
outbound.push(WsOutbound::ToolCall { outbound.push(WsOutbound::ToolCall {
id: tool_call.id.clone(), id: tool_call.id.clone(),
@ -940,10 +1018,16 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
} }
} }
"tool" => { "tool" => {
let tool_state = msg.tool_state.as_ref().unwrap_or(&ToolMessageState::Completed); let tool_state = msg
.tool_state
.as_ref()
.unwrap_or(&ToolMessageState::Completed);
match tool_state { match tool_state {
ToolMessageState::Completed => vec![WsOutbound::ToolResult { ToolMessageState::Completed => vec![WsOutbound::ToolResult {
id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), id: msg
.tool_call_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(), tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(), tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(), content: msg.content.clone(),
@ -954,7 +1038,10 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
}], }],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), id: msg
.tool_call_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(), tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(), tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(), content: msg.content.clone(),
@ -983,7 +1070,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{resolve_ws_sender_id, build_media_filename, process_attachments_with_base64}; use super::{build_media_filename, process_attachments_with_base64, resolve_ws_sender_id};
use crate::protocol::MediaSummary; use crate::protocol::MediaSummary;
use base64::{Engine as _, engine::general_purpose::STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD};

View File

@ -20,5 +20,5 @@ pub mod scheduler;
pub mod skills; pub mod skills;
pub mod storage; pub mod storage;
pub mod text; pub mod text;
pub mod topic_description;
pub mod tools; pub mod tools;
pub mod topic_description;

View File

@ -45,7 +45,9 @@ pub fn init_logging(timezone: Tz) {
static INIT: Once = Once::new(); static INIT: Once = Once::new();
let mut initialized = false; let mut initialized = false;
INIT.call_once(|| { initialized = true; }); INIT.call_once(|| {
initialized = true;
});
if !initialized { if !initialized {
// Already initialized (e.g. after gateway restart), skip // Already initialized (e.g. after gateway restart), skip
return; return;

View File

@ -40,11 +40,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::args().len() <= 1 { if std::env::args().len() <= 1 {
cmd.print_help()?; cmd.print_help()?;
println!(); println!();
return Ok(()) return Ok(());
} }
match Command::parse() { match Command::parse() {
Command::Init { force, skip_channels } => { Command::Init {
force,
skip_channels,
} => {
let mut wizard = picobot::cli::InitWizard::new(); let mut wizard = picobot::cli::InitWizard::new();
wizard.run(force, skip_channels).await?; wizard.run(force, skip_channels).await?;
} }
@ -56,14 +59,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
picobot::client::run(&url).await?; picobot::client::run(&url).await?;
} }
Command::Gateway { host, port } => { Command::Gateway { host, port } => {
loop { let mut should_restart = true;
let should_restart = picobot::gateway::run(host.clone(), port).await?; while should_restart {
if !should_restart { should_restart = picobot::gateway::run(host.clone(), port).await?;
break; if should_restart {
}
tracing::info!("Gateway restarting..."); tracing::info!("Gateway restarting...");
} }
} }
} }
}
Ok(()) Ok(())
} }

View File

@ -10,14 +10,16 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use http::{HeaderName, HeaderValue};
use rmcp::{ use rmcp::{
model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool},
RoleClient, ServiceExt, RoleClient, ServiceExt,
model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool},
service::RunningService, service::RunningService,
transport::TokioChildProcess, transport::TokioChildProcess,
transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig}, transport::streamable_http_client::{
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
},
}; };
use http::{HeaderName, HeaderValue};
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command; use tokio::process::Command;
@ -64,7 +66,11 @@ fn resolve_command_path(command: &str) -> Option<PathBuf> {
} }
// If it has a path separator (relative path), don't search PATH // If it has a path separator (relative path), don't search PATH
if path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false) { if path
.parent()
.map(|p| !p.as_os_str().is_empty())
.unwrap_or(false)
{
return None; return None;
} }
@ -208,7 +214,10 @@ impl McpClientManager {
"Failed to connect to MCP server after all retries" "Failed to connect to MCP server after all retries"
); );
// Record error for status reporting // Record error for status reporting
self.connection_errors.write().await.insert(key.clone(), e.to_string()); self.connection_errors
.write()
.await
.insert(key.clone(), e.to_string());
failed += 1; failed += 1;
} else { } else {
// Clear any previous error on successful connection // Clear any previous error on successful connection
@ -238,18 +247,23 @@ impl McpClientManager {
} }
/// Connect to a single MCP server /// Connect to a single MCP server
pub async fn connect_server(&self, key: &str, config: &McpServerConfig) -> anyhow::Result<McpServerInfo> { pub async fn connect_server(
&self,
key: &str,
config: &McpServerConfig,
) -> anyhow::Result<McpServerInfo> {
let effective_name = config.effective_name(key); let effective_name = config.effective_name(key);
tracing::info!(key = %key, name = %effective_name, transport_type = %config.transport_type, "Connecting to MCP server"); tracing::info!(key = %key, name = %effective_name, transport_type = %config.transport_type, "Connecting to MCP server");
let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?; let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?;
let client = match transport { let client = match transport {
McpTransportConfig::Stdio { command, args, env, cwd } => { McpTransportConfig::Stdio {
self.connect_stdio(key, &command, &args, &env, &cwd).await? command,
} args,
McpTransportConfig::Http { url, headers } => { env,
self.connect_http(&url, &headers).await? cwd,
} } => self.connect_stdio(key, &command, &args, &env, &cwd).await?,
McpTransportConfig::Http { url, headers } => self.connect_http(&url, &headers).await?,
}; };
// Get server info (returns Option<Arc<ServerInfo>> in rmcp 1.8+) // Get server info (returns Option<Arc<ServerInfo>> in rmcp 1.8+)
@ -299,7 +313,10 @@ impl McpClientManager {
if resolved_command.is_none() { if resolved_command.is_none() {
let path = std::path::Path::new(command); let path = std::path::Path::new(command);
let is_absolute = path.is_absolute(); let is_absolute = path.is_absolute();
let has_separator = path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false); let has_separator = path
.parent()
.map(|p| !p.as_os_str().is_empty())
.unwrap_or(false);
if !is_absolute && !has_separator && path.extension().is_none() { if !is_absolute && !has_separator && path.extension().is_none() {
// Bare name not found on Windows // Bare name not found on Windows
let path_env = std::env::var("PATH").unwrap_or_default(); let path_env = std::env::var("PATH").unwrap_or_default();
@ -308,7 +325,8 @@ impl McpClientManager {
Current PATH: {}. \ Current PATH: {}. \
Suggestion: use the full absolute path to the executable, \ Suggestion: use the full absolute path to the executable, \
or ensure the tool is installed and its directory is in PATH.", or ensure the tool is installed and its directory is in PATH.",
command, path_env command,
path_env
)); ));
} }
} }
@ -416,7 +434,8 @@ impl McpClientManager {
})?; })?;
// Track that we have a stdio (child process) connection // Track that we have a stdio (child process) connection
self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); self.stdio_client_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(client) Ok(client)
} }
@ -446,9 +465,7 @@ impl McpClientManager {
.iter() .iter()
.filter_map(|(key, value)| { .filter_map(|(key, value)| {
// Try to parse header name and value // Try to parse header name and value
HeaderName::try_from(key.clone()) HeaderName::try_from(key.clone()).ok().and_then(|name| {
.ok()
.and_then(|name| {
HeaderValue::try_from(value.clone()) HeaderValue::try_from(value.clone())
.ok() .ok()
.map(|val| (name, val)) .map(|val| (name, val))
@ -457,14 +474,12 @@ impl McpClientManager {
.collect(); .collect();
// Create transport config with custom headers // Create transport config with custom headers
let config = StreamableHttpClientTransportConfig::with_uri(url) let config =
.custom_headers(custom_headers); StreamableHttpClientTransportConfig::with_uri(url).custom_headers(custom_headers);
// Create transport using reqwest client (default) // Create transport using reqwest client (default)
let transport = StreamableHttpClientTransport::with_client( let transport =
reqwest::Client::default(), StreamableHttpClientTransport::with_client(reqwest::Client::default(), config);
config,
);
// Connect // Connect
let client = ().serve(transport).await?; let client = ().serve(transport).await?;
@ -497,7 +512,9 @@ impl McpClientManager {
info_map info_map
.values() .values()
.flat_map(|info| { .flat_map(|info| {
info.tools.iter().map(|tool| (info.key.clone(), tool.clone())) info.tools
.iter()
.map(|tool| (info.key.clone(), tool.clone()))
}) })
.collect() .collect()
} }
@ -561,7 +578,9 @@ impl McpClientManager {
/// gateway restart where old MCP processes may still be running when /// gateway restart where old MCP processes may still be running when
/// new ones start. /// new ones start.
pub async fn shutdown_all(&self) -> anyhow::Result<()> { pub async fn shutdown_all(&self) -> anyhow::Result<()> {
let stdio_count = self.stdio_client_count.load(std::sync::atomic::Ordering::SeqCst); let stdio_count = self
.stdio_client_count
.load(std::sync::atomic::Ordering::SeqCst);
// Drop all clients (triggers cancellation + graceful shutdown in rmcp) // Drop all clients (triggers cancellation + graceful shutdown in rmcp)
self.disconnect_all().await?; self.disconnect_all().await?;
@ -578,7 +597,8 @@ impl McpClientManager {
); );
tokio::time::sleep(std::time::Duration::from_secs(6)).await; tokio::time::sleep(std::time::Duration::from_secs(6)).await;
tracing::info!("MCP child process cleanup wait complete"); tracing::info!("MCP child process cleanup wait complete");
self.stdio_client_count.store(0, std::sync::atomic::Ordering::SeqCst); self.stdio_client_count
.store(0, std::sync::atomic::Ordering::SeqCst);
} }
Ok(()) Ok(())
@ -766,7 +786,10 @@ impl McpInitializer {
/// ///
/// This should be called after the gateway is ready to accept tools. /// This should be called after the gateway is ready to accept tools.
/// Waits for connections to complete before registering tools. /// Waits for connections to complete before registering tools.
pub async fn register_tools(&mut self, registry: &mut crate::tools::ToolRegistry) -> anyhow::Result<()> { pub async fn register_tools(
&mut self,
registry: &mut crate::tools::ToolRegistry,
) -> anyhow::Result<()> {
if let Some(manager) = self.manager.clone() { if let Some(manager) = self.manager.clone() {
// Wait for connections to complete first // Wait for connections to complete first
self.wait_for_connections().await?; self.wait_for_connections().await?;
@ -789,9 +812,15 @@ mod tests {
// On all platforms, this should return None (either because it's not found, // On all platforms, this should return None (either because it's not found,
// or because non-Windows always returns None) // or because non-Windows always returns None)
#[cfg(windows)] #[cfg(windows)]
assert!(result.is_none(), "Expected None for nonexistent command on Windows"); assert!(
result.is_none(),
"Expected None for nonexistent command on Windows"
);
#[cfg(not(windows))] #[cfg(not(windows))]
assert!(result.is_none(), "Expected None on non-Windows (always returns None)"); assert!(
result.is_none(),
"Expected None on non-Windows (always returns None)"
);
} }
#[test] #[test]
@ -806,7 +835,10 @@ mod tests {
#[cfg(windows)] #[cfg(windows)]
assert!(result.is_some(), "Expected to find {} on Windows", cmd); assert!(result.is_some(), "Expected to find {} on Windows", cmd);
#[cfg(not(windows))] #[cfg(not(windows))]
assert!(result.is_none(), "Expected None on non-Windows for absolute path"); assert!(
result.is_none(),
"Expected None on non-Windows for absolute path"
);
} }
#[test] #[test]
@ -820,7 +852,10 @@ mod tests {
fn test_resolve_command_path_finds_known_windows_binary() { fn test_resolve_command_path_finds_known_windows_binary() {
// cmd.exe should always be in C:\Windows\System32 which is in PATH // cmd.exe should always be in C:\Windows\System32 which is in PATH
let result = resolve_command_path("cmd"); let result = resolve_command_path("cmd");
assert!(result.is_some(), "Expected to find cmd.exe via PATH on Windows"); assert!(
result.is_some(),
"Expected to find cmd.exe via PATH on Windows"
);
if let Some(p) = result { if let Some(p) = result {
assert!( assert!(
p.to_string_lossy().to_lowercase().ends_with("cmd.exe"), p.to_string_lossy().to_lowercase().ends_with("cmd.exe"),

View File

@ -203,7 +203,11 @@ mod tests {
let config = McpServerConfig::stdio( let config = McpServerConfig::stdio(
"filesystem", "filesystem",
"npx", "npx",
vec!["-y".to_string(), "@modelcontextprotocol/server-filesystem".to_string(), "/tmp".to_string()], vec![
"-y".to_string(),
"@modelcontextprotocol/server-filesystem".to_string(),
"/tmp".to_string(),
],
); );
assert_eq!(config.name, Some("filesystem".to_string())); assert_eq!(config.name, Some("filesystem".to_string()));
@ -267,11 +271,14 @@ mod tests {
match transport { match transport {
McpTransportConfig::Stdio { command, args, .. } => { McpTransportConfig::Stdio { command, args, .. } => {
assert_eq!(command, "npx"); assert_eq!(command, "npx");
assert_eq!(args, vec![ assert_eq!(
args,
vec![
"-y", "-y",
"@modelcontextprotocol/server-filesystem", "@modelcontextprotocol/server-filesystem",
"/home/user" "/home/user"
]); ]
);
} }
_ => panic!("Expected stdio transport"), _ => panic!("Expected stdio transport"),
} }
@ -279,12 +286,18 @@ mod tests {
// Check WebSearch server (streamableHttp) // Check WebSearch server (streamableHttp)
let websearch = config.mcp_servers.get("WebSearch").unwrap(); let websearch = config.mcp_servers.get("WebSearch").unwrap();
assert_eq!(websearch.transport_type, "streamableHttp"); assert_eq!(websearch.transport_type, "streamableHttp");
assert_eq!(websearch.name, Some("AliyunBailianMCP_WebSearch".to_string())); assert_eq!(
websearch.name,
Some("AliyunBailianMCP_WebSearch".to_string())
);
assert!(websearch.is_active); assert!(websearch.is_active);
let transport = websearch.transport().unwrap(); let transport = websearch.transport().unwrap();
match transport { match transport {
McpTransportConfig::Http { url, headers } => { McpTransportConfig::Http { url, headers } => {
assert_eq!(url, "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp"); assert_eq!(
url,
"https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp"
);
assert_eq!( assert_eq!(
headers.get("Authorization"), headers.get("Authorization"),
Some(&"Bearer ${DASHSCOPE_API_KEY}".to_string()) Some(&"Bearer ${DASHSCOPE_API_KEY}".to_string())
@ -385,17 +398,31 @@ mod tests {
#[test] #[test]
fn test_http_type_alias() { fn test_http_type_alias() {
// Both "http" and "streamableHttp" should work // Both "http" and "streamableHttp" should work
let json_http = r#"{"mcpServers": {"test": {"type": "http", "baseUrl": "http://localhost"}}}"#; let json_http =
r#"{"mcpServers": {"test": {"type": "http", "baseUrl": "http://localhost"}}}"#;
let json_streamable = r#"{"mcpServers": {"test": {"type": "streamableHttp", "baseUrl": "http://localhost"}}}"#; let json_streamable = r#"{"mcpServers": {"test": {"type": "streamableHttp", "baseUrl": "http://localhost"}}}"#;
let config_http: McpConfig = serde_json::from_str(json_http).unwrap(); let config_http: McpConfig = serde_json::from_str(json_http).unwrap();
let config_streamable: McpConfig = serde_json::from_str(json_streamable).unwrap(); let config_streamable: McpConfig = serde_json::from_str(json_streamable).unwrap();
let transport_http = config_http.mcp_servers.get("test").unwrap().transport().unwrap(); let transport_http = config_http
let transport_streamable = config_streamable.mcp_servers.get("test").unwrap().transport().unwrap(); .mcp_servers
.get("test")
.unwrap()
.transport()
.unwrap();
let transport_streamable = config_streamable
.mcp_servers
.get("test")
.unwrap()
.transport()
.unwrap();
assert!(matches!(transport_http, McpTransportConfig::Http { .. })); assert!(matches!(transport_http, McpTransportConfig::Http { .. }));
assert!(matches!(transport_streamable, McpTransportConfig::Http { .. })); assert!(matches!(
transport_streamable,
McpTransportConfig::Http { .. }
));
} }
#[test] #[test]

View File

@ -11,10 +11,12 @@
//! //!
//! MCP is completely optional and disabled by default. //! MCP is completely optional and disabled by default.
pub mod config;
pub mod client; pub mod client;
pub mod config;
pub mod tool_adapter; pub mod tool_adapter;
pub use client::{
McpClient, McpClientManager, McpInitializer, McpServerInfo, McpServerStatus, McpStatusResponse,
};
pub use config::{McpConfig, McpServerConfig, McpTransportConfig}; pub use config::{McpConfig, McpServerConfig, McpTransportConfig};
pub use client::{McpClientManager, McpClient, McpServerInfo, McpInitializer, McpServerStatus, McpStatusResponse};
pub use tool_adapter::{McpToolWrapper, register_mcp_tools}; pub use tool_adapter::{McpToolWrapper, register_mcp_tools};

View File

@ -25,11 +25,7 @@ pub struct McpToolWrapper {
impl McpToolWrapper { impl McpToolWrapper {
/// Create a new tool wrapper /// Create a new tool wrapper
pub fn new( pub fn new(manager: Arc<McpClientManager>, server_key: String, tool_info: Tool) -> Self {
manager: Arc<McpClientManager>,
server_key: String,
tool_info: Tool,
) -> Self {
let tool_name = tool_info.name.clone().into_owned(); let tool_name = tool_info.name.clone().into_owned();
let full_name = format!("mcp_{}_{}", server_key, tool_name); let full_name = format!("mcp_{}_{}", server_key, tool_name);
Self { Self {
@ -128,11 +124,7 @@ pub async fn register_mcp_tools(
let all_tools = manager.all_tools().await; let all_tools = manager.all_tools().await;
for (server_key, tool_info) in all_tools { for (server_key, tool_info) in all_tools {
let wrapper = McpToolWrapper::new( let wrapper = McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info);
manager.clone(),
server_key.clone(),
tool_info,
);
tracing::info!( tracing::info!(
name = %wrapper.name(), name = %wrapper.name(),
@ -153,10 +145,7 @@ mod tests {
#[test] #[test]
fn test_extract_text_content_from_text() { fn test_extract_text_content_from_text() {
let result = CallToolResult::success(vec![ let result = CallToolResult::success(vec![Content::text("Hello"), Content::text("World")]);
Content::text("Hello"),
Content::text("World"),
]);
let text = extract_text_content(&result); let text = extract_text_content(&result);
assert_eq!(text, "Hello\nWorld"); assert_eq!(text, "Hello\nWorld");
@ -175,7 +164,8 @@ mod tests {
fn test_mcp_tool_wrapper_name() { fn test_mcp_tool_wrapper_name() {
let manager = Arc::new(McpClientManager::new()); let manager = Arc::new(McpClientManager::new());
// Create a minimal tool info using rmcp's Tool constructor // Create a minimal tool info using rmcp's Tool constructor
let schema: serde_json::Map<String, serde_json::Value> = serde_json::json!({"type": "object"}) let schema: serde_json::Map<String, serde_json::Value> =
serde_json::json!({"type": "object"})
.as_object() .as_object()
.unwrap() .unwrap()
.clone(); .clone();

View File

@ -300,7 +300,8 @@ mod tests {
fn test_truncate_args_utf8_boundary() { fn test_truncate_args_utf8_boundary() {
// Test that truncation respects UTF-8 character boundaries // Test that truncation respects UTF-8 character boundaries
// Each Chinese character is 3 bytes in UTF-8 // Each Chinese character is 3 bytes in UTF-8
let long_args = serde_json::json!({"key": "测试测试测试测试测试测试测试测试测试测试测试测试测试测试"}); let long_args =
serde_json::json!({"key": "测试测试测试测试测试测试测试测试测试测试测试测试测试测试"});
let truncated = truncate_args(&long_args, 50); let truncated = truncate_args(&long_args, 50);
assert!(truncated.ends_with("...truncated")); assert!(truncated.ends_with("...truncated"));
// Verify the truncated string is valid UTF-8 (no panic occurred) // Verify the truncated string is valid UTF-8 (no panic occurred)

View File

@ -151,11 +151,7 @@ pub fn is_process_waiting_on_stdin(pid: u32) -> Option<bool> {
if wchan.is_empty() { if wchan.is_empty() {
return None; return None;
} }
Some( Some(wchan.contains("tty_read") || wchan.contains("n_tty_read") || wchan == "pipe_wait")
wchan.contains("tty_read")
|| wchan.contains("n_tty_read")
|| wchan == "pipe_wait",
)
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {

View File

@ -240,9 +240,7 @@ pub enum WsOutbound {
channel_name: Option<String>, channel_name: Option<String>,
}, },
#[serde(rename = "channel_list")] #[serde(rename = "channel_list")]
ChannelList { ChannelList { channels: Vec<Channel> },
channels: Vec<Channel>,
},
#[serde(rename = "topic_list")] #[serde(rename = "topic_list")]
TopicList { TopicList {
topics: Vec<TopicSummary>, topics: Vec<TopicSummary>,
@ -262,7 +260,10 @@ pub enum WsOutbound {
message_count: i64, message_count: i64,
}, },
#[serde(rename = "session_saved")] #[serde(rename = "session_saved")]
SessionSaved { session_id: String, filepath: String }, SessionSaved {
session_id: String,
filepath: String,
},
#[serde(rename = "task_messages_loaded")] #[serde(rename = "task_messages_loaded")]
TaskMessagesLoaded { TaskMessagesLoaded {
task_id: String, task_id: String,
@ -273,17 +274,11 @@ pub enum WsOutbound {
summary: Option<String>, summary: Option<String>,
}, },
#[serde(rename = "scheduler_job_list")] #[serde(rename = "scheduler_job_list")]
SchedulerJobList { SchedulerJobList { jobs: Vec<SchedulerJobSummary> },
jobs: Vec<SchedulerJobSummary>,
},
#[serde(rename = "memory_list")] #[serde(rename = "memory_list")]
MemoryList { MemoryList { memories: Vec<MemorySummary> },
memories: Vec<MemorySummary>,
},
#[serde(rename = "skill_list")] #[serde(rename = "skill_list")]
SkillList { SkillList { skills: Vec<SkillSummary> },
skills: Vec<SkillSummary>,
},
#[serde(rename = "execution_cancelled")] #[serde(rename = "execution_cancelled")]
ExecutionCancelled { message: String }, ExecutionCancelled { message: String },
#[serde(rename = "stream_delta")] #[serde(rename = "stream_delta")]

View File

@ -15,7 +15,8 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
"assistant" => { "assistant" => {
if let Some(tool_calls) = &message.tool_calls { if let Some(tool_calls) = &message.tool_calls {
let mut outbound = Vec::new(); let mut outbound = Vec::new();
let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some(); let has_content_or_reasoning =
!message.content.trim().is_empty() || message.reasoning_content.is_some();
if has_content_or_reasoning { if has_content_or_reasoning {
outbound.push(WsOutbound::AssistantResponse { outbound.push(WsOutbound::AssistantResponse {
id: message.id.clone(), id: message.id.clone(),
@ -31,7 +32,11 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
} }
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复 // AssistantResponse 已携带 reasoning 时ToolCall 不再重复
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() }; let tc_reasoning = if has_content_or_reasoning {
None
} else {
message.reasoning_content.clone()
};
outbound.extend(tool_calls.iter().map(|tool_call| WsOutbound::ToolCall { outbound.extend(tool_calls.iter().map(|tool_call| WsOutbound::ToolCall {
id: tool_call.id.clone(), id: tool_call.id.clone(),
tool_call_id: tool_call.id.clone(), tool_call_id: tool_call.id.clone(),
@ -66,7 +71,10 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
.unwrap_or(&ToolMessageState::Completed) .unwrap_or(&ToolMessageState::Completed)
{ {
ToolMessageState::Completed => vec![WsOutbound::ToolResult { ToolMessageState::Completed => vec![WsOutbound::ToolResult {
id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), id: message
.tool_call_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: message.tool_call_id.clone().unwrap_or_default(), tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
tool_name: message.tool_name.clone().unwrap_or_default(), tool_name: message.tool_name.clone().unwrap_or_default(),
content: message.content.clone(), content: message.content.clone(),
@ -77,7 +85,10 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
timestamp: None, timestamp: None,
}], }],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), id: message
.tool_call_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: message.tool_call_id.clone().unwrap_or_default(), tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
tool_name: message.tool_name.clone().unwrap_or_default(), tool_name: message.tool_name.clone().unwrap_or_default(),
content: message.content.clone(), content: message.content.clone(),
@ -107,7 +118,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
}) })
.collect(); .collect();
vec![WsOutbound::AssistantResponse { vec![WsOutbound::AssistantResponse {
id: message.message_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), id: message
.message_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
content: message.content.clone(), content: message.content.clone(),
role: message.role.clone(), role: message.role.clone(),
attachments, attachments,
@ -176,8 +190,16 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
}], }],
OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted { OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted {
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(), task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
description: message.metadata.get("task_description").cloned().unwrap_or_default(), description: message
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(), .metadata
.get("task_description")
.cloned()
.unwrap_or_default(),
subagent_type: message
.metadata
.get("task_subagent_type")
.cloned()
.unwrap_or_default(),
topic_id: message.metadata.get("topic_id").cloned(), topic_id: message.metadata.get("topic_id").cloned(),
parent_task_id: message.metadata.get("parent_task_id").cloned(), parent_task_id: message.metadata.get("parent_task_id").cloned(),
tool_call_id: message.metadata.get("tool_call_id").cloned(), tool_call_id: message.metadata.get("tool_call_id").cloned(),

View File

@ -41,7 +41,9 @@ fn convert_content_blocks(
) -> Vec<serde_json::Value> { ) -> Vec<serde_json::Value> {
// 检查是否有图片且模型不支持 // 检查是否有图片且模型不支持
if !supports_images { if !supports_images {
let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. })); let has_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
if has_images { if has_images {
let image_count = blocks let image_count = blocks
@ -79,10 +81,8 @@ fn convert_content_blocks(
// 添加通知文本块 // 添加通知文本块
if !notices.is_empty() { if !notices.is_empty() {
let notice_text = format!( let notice_text =
"[系统提示] 以下图片未能成功入模:\n{}", format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
notices.join("\n")
);
converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text })); converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text }));
} }
@ -182,9 +182,7 @@ impl AnthropicProvider {
self.model_extra self.model_extra
.get("supported_content_types") .get("supported_content_types")
.and_then(|value| value.as_array()) .and_then(|value| value.as_array())
.map(|types| { .map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
types.iter().any(|t| t.as_str() == Some(content_type))
})
.unwrap_or(true) .unwrap_or(true)
} }

View File

@ -11,7 +11,11 @@ use super::traits::{StreamCallback, StreamDelta, Usage};
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall}; use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall};
use crate::domain::messages::ContentBlock; use crate::domain::messages::ContentBlock;
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &["tool_call_arguments_json", "mock_response_content", "supported_content_types"]; const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &[
"tool_call_arguments_json",
"mock_response_content",
"supported_content_types",
];
/// 流式响应中的工具调用增量 /// 流式响应中的工具调用增量
#[derive(Debug, Default)] #[derive(Debug, Default)]
@ -49,8 +53,17 @@ impl StreamingAccumulator {
} }
/// 添加工具调用增量 /// 添加工具调用增量
fn add_tool_call(&mut self, index: usize, id: Option<&str>, name: Option<&str>, arguments: Option<&str>) { fn add_tool_call(
let entry = self.tool_calls.entry(index).or_insert_with(StreamingToolCall::default); &mut self,
index: usize,
id: Option<&str>,
name: Option<&str>,
arguments: Option<&str>,
) {
let entry = self
.tool_calls
.entry(index)
.or_insert_with(StreamingToolCall::default);
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值 // 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
if let Some(id) = id { if let Some(id) = id {
@ -78,7 +91,8 @@ impl StreamingAccumulator {
/// 构建最终的 ChatCompletionResponse /// 构建最终的 ChatCompletionResponse
fn build_response(self, model: String) -> ChatCompletionResponse { fn build_response(self, model: String) -> ChatCompletionResponse {
let tool_calls: Vec<ToolCall> = self.tool_calls let tool_calls: Vec<ToolCall> = self
.tool_calls
.into_iter() .into_iter()
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty()) .filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
.map(|(_, call)| { .map(|(_, call)| {
@ -149,10 +163,13 @@ fn convert_content_blocks(
) -> Value { ) -> Value {
// 检查是否有图片且模型不支持 // 检查是否有图片且模型不支持
if !supports_images { if !supports_images {
let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. })); let has_images = blocks
.iter()
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
if has_images { if has_images {
let image_count = blocks.iter() let image_count = blocks
.iter()
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. })) .filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
.count(); .count();
@ -186,10 +203,8 @@ fn convert_content_blocks(
// 添加通知文本块 // 添加通知文本块
if !notices.is_empty() { if !notices.is_empty() {
let notice_text = format!( let notice_text =
"[系统提示] 以下图片未能成功入模:\n{}", format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
notices.join("\n")
);
converted_blocks.push(json!({ "type": "text", "text": notice_text })); converted_blocks.push(json!({ "type": "text", "text": notice_text }));
} }
@ -303,9 +318,7 @@ impl OpenAIProvider {
self.model_extra self.model_extra
.get("supported_content_types") .get("supported_content_types")
.and_then(|value| value.as_array()) .and_then(|value| value.as_array())
.map(|types| { .map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
types.iter().any(|t| t.as_str() == Some(content_type))
})
.unwrap_or(true) .unwrap_or(true)
} }
@ -339,7 +352,9 @@ impl OpenAIProvider {
Value::String(raw) Value::String(raw)
} else { } else {
// Invalid JSON string - wrap it as a proper JSON string // Invalid JSON string - wrap it as a proper JSON string
Value::String(serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string())) Value::String(
serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string()),
)
} }
} }
value => Value::String( value => Value::String(
@ -435,7 +450,8 @@ impl OpenAIProvider {
} }
// SSE 格式: data: {...} 或 data:{...}(某些 API 如 139 云没有空格) // SSE 格式: data: {...} 或 data:{...}(某些 API 如 139 云没有空格)
let data_opt = line_trimmed.strip_prefix("data: ") let data_opt = line_trimmed
.strip_prefix("data: ")
.or_else(|| line_trimmed.strip_prefix("data:")); .or_else(|| line_trimmed.strip_prefix("data:"));
if let Some(data) = data_opt { if let Some(data) = data_opt {
@ -459,7 +475,9 @@ impl OpenAIProvider {
// 尝试从 delta 提取(标准 OpenAI 流式格式) // 尝试从 delta 提取(标准 OpenAI 流式格式)
if let Some(delta) = choice.get("delta") { if let Some(delta) = choice.get("delta") {
// 提取内容增量 // 提取内容增量
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { if let Some(content) =
delta.get("content").and_then(|c| c.as_str())
{
accumulator.add_content(content); accumulator.add_content(content);
if let Some(cb) = &stream_callback { if let Some(cb) = &stream_callback {
cb(StreamDelta { cb(StreamDelta {
@ -470,7 +488,9 @@ impl OpenAIProvider {
} }
// 提取推理内容增量 // 提取推理内容增量
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) { if let Some(reasoning) =
delta.get("reasoning_content").and_then(|r| r.as_str())
{
accumulator.add_reasoning_content(reasoning); accumulator.add_reasoning_content(reasoning);
if let Some(cb) = &stream_callback { if let Some(cb) = &stream_callback {
cb(StreamDelta { cb(StreamDelta {
@ -481,28 +501,43 @@ impl OpenAIProvider {
} }
// 提取工具调用增量 // 提取工具调用增量
if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) { if let Some(tool_calls) =
delta.get("tool_calls").and_then(|t| t.as_array())
{
for tool_call in tool_calls { for tool_call in tool_calls {
let index = tool_call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; let index = tool_call
.get("index")
.and_then(|i| i.as_u64())
.unwrap_or(0)
as usize;
let id = tool_call.get("id").and_then(|v| v.as_str()); let id =
let name = tool_call.get("function") tool_call.get("id").and_then(|v| v.as_str());
let name = tool_call
.get("function")
.and_then(|f| f.get("name")) .and_then(|f| f.get("name"))
.and_then(|n| n.as_str()); .and_then(|n| n.as_str());
let arguments = tool_call.get("function") let arguments = tool_call
.get("function")
.and_then(|f| f.get("arguments")) .and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str()); .and_then(|a| a.as_str());
accumulator.add_tool_call(index, id, name, arguments); accumulator
.add_tool_call(index, id, name, arguments);
} }
} }
} }
// 尝试从 message 提取(某些非标准 API 格式) // 尝试从 message 提取(某些非标准 API 格式)
else if let Some(message) = choice.get("message") { else if let Some(message) = choice.get("message") {
if let Some(content) = message.get("content").and_then(|c| c.as_str()) { if let Some(content) =
message.get("content").and_then(|c| c.as_str())
{
accumulator.add_content(content); accumulator.add_content(content);
} }
if let Some(reasoning) = message.get("reasoning_content").and_then(|r| r.as_str()) { if let Some(reasoning) = message
.get("reasoning_content")
.and_then(|r| r.as_str())
{
accumulator.add_reasoning_content(reasoning); accumulator.add_reasoning_content(reasoning);
} }
} }
@ -533,7 +568,8 @@ impl OpenAIProvider {
} }
// 同样支持 data: {...} 和 data:{...} 两种格式 // 同样支持 data: {...} 和 data:{...} 两种格式
let data_opt = line_trimmed.strip_prefix("data: ") let data_opt = line_trimmed
.strip_prefix("data: ")
.or_else(|| line_trimmed.strip_prefix("data:")); .or_else(|| line_trimmed.strip_prefix("data:"));
if let Some(data) = data_opt { if let Some(data) = data_opt {
@ -550,7 +586,8 @@ impl OpenAIProvider {
for choice in choices { for choice in choices {
// 尝试从 delta 提取 // 尝试从 delta 提取
if let Some(delta) = choice.get("delta") { if let Some(delta) = choice.get("delta") {
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { if let Some(content) = delta.get("content").and_then(|c| c.as_str())
{
accumulator.add_content(content); accumulator.add_content(content);
if let Some(cb) = &stream_callback { if let Some(cb) = &stream_callback {
cb(StreamDelta { cb(StreamDelta {
@ -559,7 +596,9 @@ impl OpenAIProvider {
}); });
} }
} }
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) { if let Some(reasoning) =
delta.get("reasoning_content").and_then(|r| r.as_str())
{
accumulator.add_reasoning_content(reasoning); accumulator.add_reasoning_content(reasoning);
if let Some(cb) = &stream_callback { if let Some(cb) = &stream_callback {
cb(StreamDelta { cb(StreamDelta {
@ -568,14 +607,22 @@ impl OpenAIProvider {
}); });
} }
} }
if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) { if let Some(tool_calls) =
delta.get("tool_calls").and_then(|t| t.as_array())
{
for tool_call in tool_calls { for tool_call in tool_calls {
let index = tool_call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; let index = tool_call
.get("index")
.and_then(|i| i.as_u64())
.unwrap_or(0)
as usize;
let id = tool_call.get("id").and_then(|v| v.as_str()); let id = tool_call.get("id").and_then(|v| v.as_str());
let name = tool_call.get("function") let name = tool_call
.get("function")
.and_then(|f| f.get("name")) .and_then(|f| f.get("name"))
.and_then(|n| n.as_str()); .and_then(|n| n.as_str());
let arguments = tool_call.get("function") let arguments = tool_call
.get("function")
.and_then(|f| f.get("arguments")) .and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str()); .and_then(|a| a.as_str());
accumulator.add_tool_call(index, id, name, arguments); accumulator.add_tool_call(index, id, name, arguments);
@ -584,10 +631,14 @@ impl OpenAIProvider {
} }
// 尝试从 message 提取(某些非标准 API 格式) // 尝试从 message 提取(某些非标准 API 格式)
else if let Some(message) = choice.get("message") { else if let Some(message) = choice.get("message") {
if let Some(content) = message.get("content").and_then(|c| c.as_str()) { if let Some(content) =
message.get("content").and_then(|c| c.as_str())
{
accumulator.add_content(content); accumulator.add_content(content);
} }
if let Some(reasoning) = message.get("reasoning_content").and_then(|r| r.as_str()) { if let Some(reasoning) =
message.get("reasoning_content").and_then(|r| r.as_str())
{
accumulator.add_reasoning_content(reasoning); accumulator.add_reasoning_content(reasoning);
} }
} }
@ -603,7 +654,8 @@ impl OpenAIProvider {
// 服务器可能返回的是非 SSE 格式的纯 JSON尝试直接反序列化整个响应体 // 服务器可能返回的是非 SSE 格式的纯 JSON尝试直接反序列化整个响应体
if response.content.is_empty() && response.tool_calls.is_empty() { if response.content.is_empty() && response.tool_calls.is_empty() {
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) { if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
let fallback_content = openai_resp.choices let fallback_content = openai_resp
.choices
.first() .first()
.and_then(|c| c.message.content.as_deref()) .and_then(|c| c.message.content.as_deref())
.unwrap_or("") .unwrap_or("")
@ -614,22 +666,29 @@ impl OpenAIProvider {
"Streaming accumulator empty, falling back to non-SSE JSON parsing" "Streaming accumulator empty, falling back to non-SSE JSON parsing"
); );
response.content = fallback_content; response.content = fallback_content;
response.reasoning_content = openai_resp.choices response.reasoning_content = openai_resp
.choices
.first() .first()
.and_then(|c| c.message.reasoning_content.clone()); .and_then(|c| c.message.reasoning_content.clone());
response.tool_calls = openai_resp.choices response.tool_calls = openai_resp
.choices
.first() .first()
.map(|c| { .map(|c| {
c.message.tool_calls.iter().map(|tc| ToolCall { c.message
.tool_calls
.iter()
.map(|tc| ToolCall {
id: tc.id.clone(), id: tc.id.clone(),
name: tc.function.name.clone(), name: tc.function.name.clone(),
arguments: match &tc.function.arguments { arguments: match &tc.function.arguments {
OAIFunctionArguments::Json(args) => args.clone(), OAIFunctionArguments::Json(args) => args.clone(),
OAIFunctionArguments::String(args) => { OAIFunctionArguments::String(args) => {
serde_json::from_str(args).unwrap_or(serde_json::Value::Null) serde_json::from_str(args)
.unwrap_or(serde_json::Value::Null)
} }
}, },
}).collect() })
.collect()
}) })
.unwrap_or_default(); .unwrap_or_default();
} }
@ -656,9 +715,11 @@ impl OpenAIProvider {
// result that precedes its parent assistant (e.g. after compaction // result that precedes its parent assistant (e.g. after compaction
// boundary splits), leading to API 400 errors: // boundary splits), leading to API 400 errors:
// "insufficient tool messages following tool_calls message". // "insufficient tool messages following tool_calls message".
let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut resolved_tool_ids: std::collections::HashSet<&str> =
std::collections::HashSet::new();
let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut skip_assistant_indices: std::collections::HashSet<usize> = std::collections::HashSet::new(); let mut skip_assistant_indices: std::collections::HashSet<usize> =
std::collections::HashSet::new();
for (i, m) in request.messages.iter().enumerate().rev() { for (i, m) in request.messages.iter().enumerate().rev() {
if m.role == "tool" { if m.role == "tool" {
@ -670,8 +731,9 @@ impl OpenAIProvider {
if m.role == "assistant" { if m.role == "assistant" {
if let Some(ref calls) = m.tool_calls { if let Some(ref calls) = m.tool_calls {
if !calls.is_empty() { if !calls.is_empty() {
let all_resolved = let all_resolved = calls
calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str())); .iter()
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
if all_resolved { if all_resolved {
for tc in calls { for tc in calls {
with_parent.insert(tc.id.as_str()); with_parent.insert(tc.id.as_str());
@ -695,7 +757,8 @@ impl OpenAIProvider {
// ^ reverse scan sees tool(A) after assistant → "resolved" // ^ reverse scan sees tool(A) after assistant → "resolved"
// but API requires tool(A) to be IMMEDIATELY after assistant // but API requires tool(A) to be IMMEDIATELY after assistant
{ {
let mut pending_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut pending_tool_ids: std::collections::HashSet<&str> =
std::collections::HashSet::new();
let mut pending_assistant_idx: Option<usize> = None; let mut pending_assistant_idx: Option<usize> = None;
for (i, m) in request.messages.iter().enumerate() { for (i, m) in request.messages.iter().enumerate() {
@ -892,12 +955,17 @@ impl OpenAIProvider {
/// avoid flooding logs on every request — see callers in `chat` and /// avoid flooding logs on every request — see callers in `chat` and
/// `chat_streaming_internal`. /// `chat_streaming_internal`.
fn format_message_sequence(body: &Value) -> Vec<String> { fn format_message_sequence(body: &Value) -> Vec<String> {
body["messages"].as_array() body["messages"]
.map(|msgs| msgs.iter().enumerate().map(|(i, m)| { .as_array()
.map(|msgs| {
msgs.iter()
.enumerate()
.map(|(i, m)| {
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?"); let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
match role { match role {
"assistant" => { "assistant" => {
let tc_count = m.get("tool_calls") let tc_count = m
.get("tool_calls")
.and_then(|t| t.as_array()) .and_then(|t| t.as_array())
.map(|a| a.len()) .map(|a| a.len())
.unwrap_or(0); .unwrap_or(0);
@ -908,14 +976,17 @@ fn format_message_sequence(body: &Value) -> Vec<String> {
} }
} }
"tool" => { "tool" => {
let tcid = m.get("tool_call_id") let tcid = m
.get("tool_call_id")
.and_then(|t| t.as_str()) .and_then(|t| t.as_str())
.unwrap_or("??"); .unwrap_or("??");
format!("[{}] tool(id={})", i, tcid) format!("[{}] tool(id={})", i, tcid)
} }
_ => format!("[{}] {}", i, role), _ => format!("[{}] {}", i, role),
} }
}).collect()) })
.collect()
})
.unwrap_or_default() .unwrap_or_default()
} }
@ -1141,7 +1212,10 @@ impl LLMProvider for OpenAIProvider {
callback: StreamCallback, callback: StreamCallback,
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
if self.is_streaming_enabled() { if self.is_streaming_enabled() {
match self.chat_streaming_internal(&request, Some(&callback)).await { match self
.chat_streaming_internal(&request, Some(&callback))
.await
{
Ok(response) => return Ok(response), Ok(response) => return Ok(response),
Err(e) => { Err(e) => {
tracing::debug!( tracing::debug!(
@ -1471,7 +1545,12 @@ mod tests {
let mut accumulator = StreamingAccumulator::new(); let mut accumulator = StreamingAccumulator::new();
// 第一个 chunk包含完整的 id 和 name // 第一个 chunk包含完整的 id 和 name
accumulator.add_tool_call(0, Some("call_abc123"), Some("memory_search"), Some("{\"action\":\"")); accumulator.add_tool_call(
0,
Some("call_abc123"),
Some("memory_search"),
Some("{\"action\":\""),
);
// 第二个 chunk只有参数增量 // 第二个 chunk只有参数增量
accumulator.add_tool_call(0, None, None, Some("list")); accumulator.add_tool_call(0, None, None, Some("list"));
// 第三个 chunk参数继续 // 第三个 chunk参数继续
@ -1487,7 +1566,10 @@ mod tests {
assert_eq!(response.tool_calls.len(), 1); assert_eq!(response.tool_calls.len(), 1);
assert_eq!(response.tool_calls[0].id, "call_abc123"); assert_eq!(response.tool_calls[0].id, "call_abc123");
assert_eq!(response.tool_calls[0].name, "memory_search"); assert_eq!(response.tool_calls[0].name, "memory_search");
assert_eq!(response.tool_calls[0].arguments, json!({"action":"list", "limit": 20})); assert_eq!(
response.tool_calls[0].arguments,
json!({"action":"list", "limit": 20})
);
} }
#[test] #[test]
@ -1495,7 +1577,12 @@ mod tests {
let mut accumulator = StreamingAccumulator::new(); let mut accumulator = StreamingAccumulator::new();
// 第一个工具调用 // 第一个工具调用
accumulator.add_tool_call(0, Some("call_1"), Some("calculator"), Some("{\"expr\": \"1+1\"}")); accumulator.add_tool_call(
0,
Some("call_1"),
Some("calculator"),
Some("{\"expr\": \"1+1\"}"),
);
// 第二个工具调用id 和 name 只在第一个 chunk 出现) // 第二个工具调用id 和 name 只在第一个 chunk 出现)
accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}")); accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}"));
@ -1600,7 +1687,10 @@ mod tests {
"supported_content_types".to_string(), "supported_content_types".to_string(),
Value::Array(vec![Value::String("text".to_string())]), Value::Array(vec![Value::String("text".to_string())]),
), ),
("custom_param".to_string(), Value::String("value".to_string())), (
"custom_param".to_string(),
Value::String("value".to_string()),
),
]), ]),
); );
@ -1737,7 +1827,8 @@ mod tests {
let messages = body["messages"].as_array().unwrap(); let messages = body["messages"].as_array().unwrap();
// Assistant should keep tool_calls (valid immediate sequence) // Assistant should keep tool_calls (valid immediate sequence)
let tool_calls = messages[0].get("tool_calls") let tool_calls = messages[0]
.get("tool_calls")
.and_then(|t| t.as_array()) .and_then(|t| t.as_array())
.expect("tool_calls should be preserved when immediately followed"); .expect("tool_calls should be preserved when immediately followed");
assert_eq!(tool_calls.len(), 1); assert_eq!(tool_calls.len(), 1);

View File

@ -1,6 +1,6 @@
use crate::config::LLMProviderConfig;
use crate::domain::messages::{ContentBlock, ToolCall}; use crate::domain::messages::{ContentBlock, ToolCall};
use crate::domain::tools::Tool; use crate::domain::tools::Tool;
use crate::config::LLMProviderConfig;
use async_trait::async_trait; use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;

View File

@ -59,7 +59,9 @@ pub trait AgentTaskExecutor: Send + Sync {
pub trait MaintenanceExecutor: Send + Sync { pub trait MaintenanceExecutor: Send + Sync {
async fn cleanup_expired_sessions(&self) -> usize; async fn cleanup_expired_sessions(&self) -> usize;
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>>; async fn run_memory_maintenance_for_all_scopes(
&self,
) -> anyhow::Result<Vec<MaintenanceRunSummary>>;
} }
pub struct Scheduler { pub struct Scheduler {
@ -452,11 +454,15 @@ fn scheduler_job_definition_matches(
existing: &SchedulerJobRecord, existing: &SchedulerJobRecord,
) -> bool { ) -> bool {
let input_schedule = serde_json::from_value::<SchedulerSchedule>(input.schedule.clone()).ok(); let input_schedule = serde_json::from_value::<SchedulerSchedule>(input.schedule.clone()).ok();
let existing_schedule = let existing_schedule = deserialize_schedule(
deserialize_schedule(&existing.schedule, existing.interval_secs, existing.startup_delay_secs) &existing.schedule,
existing.interval_secs,
existing.startup_delay_secs,
)
.ok(); .ok();
let input_target = serde_json::from_value::<SchedulerJobTarget>(input.target.clone()).ok(); let input_target = serde_json::from_value::<SchedulerJobTarget>(input.target.clone()).ok();
let existing_target = serde_json::from_value::<SchedulerJobTarget>(existing.target.clone()).ok(); let existing_target =
serde_json::from_value::<SchedulerJobTarget>(existing.target.clone()).ok();
let targets_match = match (input_target, existing_target) { let targets_match = match (input_target, existing_target) {
(Some(input_target), Some(existing_target)) => { (Some(input_target), Some(existing_target)) => {
input_target.channel == existing_target.channel input_target.channel == existing_target.channel
@ -813,7 +819,10 @@ fn convert_weekday_field(expression: &str) -> String {
let weekday_field = parts[5]; let weekday_field = parts[5];
let converted = convert_cron_weekday(weekday_field); let converted = convert_cron_weekday(weekday_field);
format!("{} {} {} {} {} {}", parts[0], parts[1], parts[2], parts[3], parts[4], converted) format!(
"{} {} {} {} {} {}",
parts[0], parts[1], parts[2], parts[3], parts[4], converted
)
} }
/// 转换星期表达式中的数字 /// 转换星期表达式中的数字
@ -824,9 +833,10 @@ fn convert_cron_weekday(field: &str) -> String {
// 处理列表(逗号分隔) // 处理列表(逗号分隔)
let items: Vec<&str> = field.split(',').collect(); let items: Vec<&str> = field.split(',').collect();
let converted_items: Vec<String> = items.iter().map(|item| { let converted_items: Vec<String> = items
convert_weekday_item(item.trim()) .iter()
}).collect(); .map(|item| convert_weekday_item(item.trim()))
.collect();
converted_items.join(",") converted_items.join(",")
} }
@ -929,7 +939,9 @@ async fn execute_internal_event(
Ok(()) Ok(())
} }
"memory_maintenance" => { "memory_maintenance" => {
let results = maintenance_executor.run_memory_maintenance_for_all_scopes().await?; let results = maintenance_executor
.run_memory_maintenance_for_all_scopes()
.await?;
for result in &results { for result in &results {
tracing::info!( tracing::info!(
job_id = %job.id, job_id = %job.id,
@ -1284,10 +1296,10 @@ impl TryFrom<serde_json::Value> for SchedulerJobTarget {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::{Datelike, Timelike};
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::config::BUILTIN_MEMORY_MAINTENANCE_JOB_ID; use crate::config::BUILTIN_MEMORY_MAINTENANCE_JOB_ID;
use crate::storage::{SchedulerJobUpsert, SessionStore}; use crate::storage::{SchedulerJobUpsert, SessionStore};
use chrono::{Datelike, Timelike};
#[derive(Clone)] #[derive(Clone)]
struct TestAgentTaskExecutor; struct TestAgentTaskExecutor;
@ -1325,7 +1337,9 @@ mod tests {
0 0
} }
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>> { async fn run_memory_maintenance_for_all_scopes(
&self,
) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
Ok(Vec::new()) Ok(Vec::new())
} }
} }
@ -1592,17 +1606,19 @@ mod tests {
let probe_runtime = RuntimeJob::from_config( let probe_runtime = RuntimeJob::from_config(
&config_job, &config_job,
Utc.timestamp_millis_opt(1_700_000_000_000).single().unwrap(), Utc.timestamp_millis_opt(1_700_000_000_000)
.single()
.unwrap(),
SchedulerMisfirePolicy::Skip, SchedulerMisfirePolicy::Skip,
chrono_tz::Asia::Shanghai, chrono_tz::Asia::Shanghai,
) )
.unwrap(); .unwrap();
let probe_existing = store let probe_existing = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
.get_scheduler_job("agent.heartbeat")
.unwrap()
.unwrap();
let probe_upsert = probe_runtime.to_upsert(); let probe_upsert = probe_runtime.to_upsert();
assert!(scheduler_job_definition_matches(&probe_upsert, &probe_existing)); assert!(scheduler_job_definition_matches(
&probe_upsert,
&probe_existing
));
let (agent_task_executor, maintenance_service) = test_scheduler_services(); let (agent_task_executor, maintenance_service) = test_scheduler_services();
let scheduler = Scheduler::new( let scheduler = Scheduler::new(
@ -1622,10 +1638,7 @@ mod tests {
scheduler.sync_config_jobs().unwrap(); scheduler.sync_config_jobs().unwrap();
let saved = store let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
.get_scheduler_job("agent.heartbeat")
.unwrap()
.unwrap();
assert_eq!(saved.next_fire_at, Some(persisted_next_fire_at)); assert_eq!(saved.next_fire_at, Some(persisted_next_fire_at));
assert_eq!(saved.run_count, 3); assert_eq!(saved.run_count, 3);
@ -1723,7 +1736,6 @@ mod tests {
); );
} }
#[test] #[test]
fn debug_cron_weekday_definitions() { fn debug_cron_weekday_definitions() {
// 重大发现cron crate 的星期定义是反常规的! // 重大发现cron crate 的星期定义是反常规的!
@ -1742,9 +1754,16 @@ mod tests {
]; ];
// 从周六2026-04-25开始测试 // 从周六2026-04-25开始测试
let saturday = Utc.with_ymd_and_hms(2026, 4, 25, 10, 0, 0).single().unwrap(); let saturday = Utc
.with_ymd_and_hms(2026, 4, 25, 10, 0, 0)
.single()
.unwrap();
let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai); let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai);
println!("\n=== 从周六 {} ({:?}) 开始测试 ===", shanghai_saturday, shanghai_saturday.weekday()); println!(
"\n=== 从周六 {} ({:?}) 开始测试 ===",
shanghai_saturday,
shanghai_saturday.weekday()
);
for (expr, desc) in &test_cases { for (expr, desc) in &test_cases {
let schedule = parse_scheduler_cron(expr).unwrap(); let schedule = parse_scheduler_cron(expr).unwrap();
@ -1757,21 +1776,49 @@ mod tests {
let schedule_workday = parse_scheduler_cron("0 9 * * 1-5").unwrap(); let schedule_workday = parse_scheduler_cron("0 9 * * 1-5").unwrap();
let sat_next = schedule_workday.after(&shanghai_saturday).next().unwrap(); let sat_next = schedule_workday.after(&shanghai_saturday).next().unwrap();
println!("周六 -> 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday()); println!(
assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周六跳到周一"); "周六 -> 1-5 下次执行: {} (星期: {:?})",
sat_next,
sat_next.weekday()
);
assert_eq!(
sat_next.weekday(),
chrono::Weekday::Mon,
"1-5 应该从周六跳到周一"
);
// 从周日开始 // 从周日开始
let sunday = Utc.with_ymd_and_hms(2026, 4, 26, 10, 0, 0).single().unwrap(); let sunday = Utc
.with_ymd_and_hms(2026, 4, 26, 10, 0, 0)
.single()
.unwrap();
let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai); let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai);
let sun_next = schedule_workday.after(&shanghai_sunday).next().unwrap(); let sun_next = schedule_workday.after(&shanghai_sunday).next().unwrap();
println!("周日 -> 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday()); println!(
assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周日跳到周一"); "周日 -> 1-5 下次执行: {} (星期: {:?})",
sun_next,
sun_next.weekday()
);
assert_eq!(
sun_next.weekday(),
chrono::Weekday::Mon,
"1-5 应该从周日跳到周一"
);
// 从周一早上7点开始 // 从周一早上7点开始
let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap(); let shanghai_monday = chrono_tz::Asia::Shanghai
println!("周一早上7点 -> 1-5 下次执行: {} (星期: {:?})", .with_ymd_and_hms(2026, 4, 27, 7, 0, 0)
.single()
.unwrap();
println!(
"周一早上7点 -> 1-5 下次执行: {} (星期: {:?})",
schedule_workday.after(&shanghai_monday).next().unwrap(), schedule_workday.after(&shanghai_monday).next().unwrap(),
schedule_workday.after(&shanghai_monday).next().unwrap().weekday()); schedule_workday
.after(&shanghai_monday)
.next()
.unwrap()
.weekday()
);
} }
/// 测试标准 cron 星期转换功能 /// 测试标准 cron 星期转换功能
@ -1784,36 +1831,77 @@ mod tests {
#[test] #[test]
fn standard_cron_weekday_conversion() { fn standard_cron_weekday_conversion() {
// 测试:标准 cron 的 1-5 应该表示周一到周五 // 测试:标准 cron 的 1-5 应该表示周一到周五
let saturday = Utc.with_ymd_and_hms(2026, 4, 25, 10, 0, 0).single().unwrap(); let saturday = Utc
.with_ymd_and_hms(2026, 4, 25, 10, 0, 0)
.single()
.unwrap();
let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai); let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai);
// 现在使用标准 cron1-5 表示周一到周五 // 现在使用标准 cron1-5 表示周一到周五
let schedule_std = parse_scheduler_cron("0 9 * * 1-5").unwrap(); let schedule_std = parse_scheduler_cron("0 9 * * 1-5").unwrap();
let sat_next = schedule_std.after(&shanghai_saturday).next().unwrap(); let sat_next = schedule_std.after(&shanghai_saturday).next().unwrap();
println!("周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday()); println!(
assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周六跳到周一"); "周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
sat_next,
sat_next.weekday()
);
assert_eq!(
sat_next.weekday(),
chrono::Weekday::Mon,
"标准 cron 1-5 应该从周六跳到周一"
);
// 从周日开始 // 从周日开始
let sunday = Utc.with_ymd_and_hms(2026, 4, 26, 10, 0, 0).single().unwrap(); let sunday = Utc
.with_ymd_and_hms(2026, 4, 26, 10, 0, 0)
.single()
.unwrap();
let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai); let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai);
let sun_next = schedule_std.after(&shanghai_sunday).next().unwrap(); let sun_next = schedule_std.after(&shanghai_sunday).next().unwrap();
println!("周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday()); println!(
assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周日跳到周一"); "周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
sun_next,
sun_next.weekday()
);
assert_eq!(
sun_next.weekday(),
chrono::Weekday::Mon,
"标准 cron 1-5 应该从周日跳到周一"
);
// 从周一开始上海时间周一早上7点 // 从周一开始上海时间周一早上7点
let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap(); let shanghai_monday = chrono_tz::Asia::Shanghai
.with_ymd_and_hms(2026, 4, 27, 7, 0, 0)
.single()
.unwrap();
let mon_next = schedule_std.after(&shanghai_monday).next().unwrap(); let mon_next = schedule_std.after(&shanghai_monday).next().unwrap();
println!("周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", mon_next, mon_next.weekday()); println!(
assert_eq!(mon_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该当天执行"); "周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
mon_next,
mon_next.weekday()
);
assert_eq!(
mon_next.weekday(),
chrono::Weekday::Mon,
"标准 cron 1-5 应该当天执行"
);
assert_eq!(mon_next.hour(), 9, "应该是上海时间9点"); assert_eq!(mon_next.hour(), 9, "应该是上海时间9点");
// 从周五开始(应该下周周一) // 从周五开始(应该下周周一)
let friday = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).single().unwrap(); // 周五 let friday = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).single().unwrap(); // 周五
let shanghai_friday = friday.with_timezone(&chrono_tz::Asia::Shanghai); let shanghai_friday = friday.with_timezone(&chrono_tz::Asia::Shanghai);
let fri_next = schedule_std.after(&shanghai_friday).next().unwrap(); let fri_next = schedule_std.after(&shanghai_friday).next().unwrap();
println!("周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", fri_next, fri_next.weekday()); println!(
assert_eq!(fri_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周五跳到下周一"); "周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
fri_next,
fri_next.weekday()
);
assert_eq!(
fri_next.weekday(),
chrono::Weekday::Mon,
"标准 cron 1-5 应该从周五跳到下周一"
);
} }
/// 测试转换辅助函数 /// 测试转换辅助函数

View File

@ -1,4 +1,6 @@
use crate::platform::{atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape}; use crate::platform::{
atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -11,7 +13,9 @@ static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)] #[cfg(test)]
pub(crate) fn acquire_skill_test_env_lock() -> std::sync::MutexGuard<'static, ()> { pub(crate) fn acquire_skill_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
SKILL_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()) SKILL_TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner())
} }
use crate::config::SkillsConfig; use crate::config::SkillsConfig;
@ -209,16 +213,23 @@ impl SkillRuntime {
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd); let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
let disable_state = load_skill_disable_state(&cwd); let disable_state = load_skill_disable_state(&cwd);
catalog.skills.iter().map(|skill| { catalog
.skills
.iter()
.map(|skill| {
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name); let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
SkillWithStatus { SkillWithStatus {
name: skill.name.clone(), name: skill.name.clone(),
description: skill.description.clone(), description: skill.description.clone(),
source: skill.source.as_str().to_string(), source: skill.source.as_str().to_string(),
path: skill.path.display().to_string(), path: skill.path.display().to_string(),
disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(), disabled_in_scopes: disabled_scopes
.iter()
.map(|s| s.as_str().to_string())
.collect(),
} }
}).collect() })
.collect()
} }
pub fn get_skill(&self, name: &str) -> Option<Skill> { pub fn get_skill(&self, name: &str) -> Option<Skill> {
@ -321,8 +332,8 @@ impl SkillRuntime {
pub fn has_skill_definition(&self, name: &str) -> Result<bool, String> { pub fn has_skill_definition(&self, name: &str) -> Result<bool, String> {
validate_skill_name(name)?; validate_skill_name(name)?;
let cwd = std::env::current_dir() let cwd =
.map_err(|err| format!("failed to get current dir: {}", err))?; std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(SkillCatalog::discover_without_state(&self.config, &cwd) Ok(SkillCatalog::discover_without_state(&self.config, &cwd)
.find_skill(name) .find_skill(name)
.is_some()) .is_some())
@ -358,8 +369,8 @@ impl SkillRuntime {
let _ = self.reload()?; let _ = self.reload()?;
} }
let cwd = std::env::current_dir() let cwd =
.map_err(|err| format!("failed to get current dir: {}", err))?; std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
let effective_state = load_skill_disable_state(&cwd); let effective_state = load_skill_disable_state(&cwd);
let disabled_in_scopes = effective_state.disabled_scopes_for(name); let disabled_in_scopes = effective_state.disabled_scopes_for(name);
@ -756,8 +767,9 @@ fn skill_file_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
fn skill_state_path(scope: SkillScope) -> Result<PathBuf, String> { fn skill_state_path(scope: SkillScope) -> Result<PathBuf, String> {
match scope { match scope {
SkillScope::User => user_skill_state_path() SkillScope::User => {
.ok_or_else(|| "failed to resolve home directory".to_string()), user_skill_state_path().ok_or_else(|| "failed to resolve home directory".to_string())
}
SkillScope::Project => { SkillScope::Project => {
let cwd = std::env::current_dir() let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?; .map_err(|err| format!("failed to get current dir: {}", err))?;
@ -966,10 +978,7 @@ impl SystemPromptProvider for SkillPromptProvider {
// 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认) // 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认)
let content = match context.session_id.as_deref() { let content = match context.session_id.as_deref() {
Some(sid) => { Some(sid) => {
let policy = self let policy = self.experts.selected_expert_for(sid).map(|e| e.capability);
.experts
.selected_expert_for(sid)
.map(|e| e.capability);
match policy { match policy {
Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered( Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered(
p.allowed_skills.as_deref(), p.allowed_skills.as_deref(),
@ -1058,7 +1067,11 @@ mod tests {
let skill_dir = dir.path().join("demo"); let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap(); fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md"); let skill_md = skill_dir.join("SKILL.md");
fs::write(&skill_md, "---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B").unwrap(); fs::write(
&skill_md,
"---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B",
)
.unwrap();
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap(); let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
assert_eq!(skill.name, "demo"); assert_eq!(skill.name, "demo");
@ -1128,7 +1141,10 @@ mod tests {
// 验证 location 包含正确的 file:// URI 格式 // 验证 location 包含正确的 file:// URI 格式
let expected_uri = path_to_uri(&skill_path); let expected_uri = path_to_uri(&skill_path);
assert!(prompt.contains(&format!("<location>{}</location>", platform_xml_escape(&expected_uri)))); assert!(prompt.contains(&format!(
"<location>{}</location>",
platform_xml_escape(&expected_uri)
)));
assert!(prompt.contains("</available_skills>")); assert!(prompt.contains("</available_skills>"));
} }
@ -1388,13 +1404,17 @@ mod tests {
max_listed_skills: 32, max_listed_skills: 32,
}); });
let disabled = runtime.disable_skill(SkillScope::Project, "demo", true).unwrap(); let disabled = runtime
.disable_skill(SkillScope::Project, "demo", true)
.unwrap();
assert!(disabled.changed); assert!(disabled.changed);
assert_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]); assert_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]);
assert!(!disabled.available); assert!(!disabled.available);
assert!(runtime.get_skill("demo").is_none()); assert!(runtime.get_skill("demo").is_none());
let enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap(); let enabled = runtime
.enable_skill(SkillScope::Project, "demo", true)
.unwrap();
assert!(enabled.changed); assert!(enabled.changed);
assert!(enabled.disabled_in_scopes.is_empty()); assert!(enabled.disabled_in_scopes.is_empty());
assert!(enabled.available); assert!(enabled.available);
@ -1427,16 +1447,22 @@ mod tests {
max_listed_skills: 32, max_listed_skills: 32,
}); });
let user_disabled = runtime.disable_skill(SkillScope::User, "demo", true).unwrap(); let user_disabled = runtime
.disable_skill(SkillScope::User, "demo", true)
.unwrap();
assert_eq!(user_disabled.disabled_in_scopes, vec![SkillScope::User]); assert_eq!(user_disabled.disabled_in_scopes, vec![SkillScope::User]);
assert!(runtime.get_skill("demo").is_none()); assert!(runtime.get_skill("demo").is_none());
let project_enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap(); let project_enabled = runtime
.enable_skill(SkillScope::Project, "demo", true)
.unwrap();
assert!(!project_enabled.available); assert!(!project_enabled.available);
assert_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]); assert_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]);
assert!(runtime.get_skill("demo").is_none()); assert!(runtime.get_skill("demo").is_none());
let user_enabled = runtime.enable_skill(SkillScope::User, "demo", true).unwrap(); let user_enabled = runtime
.enable_skill(SkillScope::User, "demo", true)
.unwrap();
assert!(user_enabled.available); assert!(user_enabled.available);
assert!(user_enabled.disabled_in_scopes.is_empty()); assert!(user_enabled.disabled_in_scopes.is_empty());
assert!(runtime.get_skill("demo").is_some()); assert!(runtime.get_skill("demo").is_some());
@ -1506,7 +1532,9 @@ mod tests {
}); });
assert_eq!(catalog.len(), 1); assert_eq!(catalog.len(), 1);
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap(); let payload = catalog
.activation_event_payload("demo-user-openclaw")
.unwrap();
assert_eq!(payload["source"], "user_openclaw"); assert_eq!(payload["source"], "user_openclaw");
} }
@ -1565,7 +1593,9 @@ mod tests {
); );
// After enabling, list_skills_with_status should report no disabled scopes // After enabling, list_skills_with_status should report no disabled scopes
runtime.enable_skill(SkillScope::Project, "demo", true).unwrap(); runtime
.enable_skill(SkillScope::Project, "demo", true)
.unwrap();
let skills_after = runtime.list_skills_with_status(); let skills_after = runtime.list_skills_with_status();
assert_eq!(skills_after.len(), 1); assert_eq!(skills_after.len(), 1);
assert!(skills_after[0].disabled_in_scopes.is_empty()); assert!(skills_after[0].disabled_in_scopes.is_empty());

View File

@ -24,10 +24,10 @@ pub use ports::{
SkillEventRepository, TodoRepository, SkillEventRepository, TodoRepository,
}; };
pub use records::{ pub use records::{
allowed_namespace_names, get_namespace_description, is_valid_namespace,
ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord, ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord,
SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord, SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord,
TodoRecord, TopicRecord, TodoRecord, TopicRecord, allowed_namespace_names, get_namespace_description,
is_valid_namespace,
}; };
#[derive(Clone)] #[derive(Clone)]
@ -228,14 +228,11 @@ impl SessionStore {
drop(conn); drop(conn);
let manager = SqliteConnectionManager::file(db_uri) let manager = SqliteConnectionManager::file(db_uri).with_init(|c| {
.with_init(|c| {
c.busy_timeout(std::time::Duration::from_secs(30))?; c.busy_timeout(std::time::Duration::from_secs(30))?;
Ok(()) Ok(())
}); });
let pool = Pool::builder() let pool = Pool::builder().max_size(8).build(manager)?;
.max_size(8)
.build(manager)?;
Ok(Self { pool }) Ok(Self { pool })
} }
@ -245,8 +242,7 @@ impl SessionStore {
// Use a temp file so the database survives across pool connections. // Use a temp file so the database survives across pool connections.
// Temp dir is cleaned by the OS eventually; tests that need cleanup // Temp dir is cleaned by the OS eventually; tests that need cleanup
// can call std::fs::remove_file on the path. // can call std::fs::remove_file on the path.
let path = std::env::temp_dir() let path = std::env::temp_dir().join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
.join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
let conn = Connection::open(&path)?; let conn = Connection::open(&path)?;
let path_str = path.to_string_lossy().to_string(); let path_str = path.to_string_lossy().to_string();
// ignore unused mut warning for manager in tests // ignore unused mut warning for manager in tests
@ -304,7 +300,12 @@ impl SessionStore {
chat_id: &str, chat_id: &str,
) -> Result<SessionRecord, StorageError> { ) -> Result<SessionRecord, StorageError> {
let session_id = persistent_session_id(channel_name, chat_id); let session_id = persistent_session_id(channel_name, chat_id);
self.ensure_session(&session_id, channel_name, chat_id, &format!("{}:{}", channel_name, chat_id)) self.ensure_session(
&session_id,
channel_name,
chat_id,
&format!("{}:{}", channel_name, chat_id),
)
} }
/// 确保指定 session_id 的会话存在(如果不存在则创建) /// 确保指定 session_id 的会话存在(如果不存在则创建)
@ -512,7 +513,11 @@ impl SessionStore {
Ok(()) Ok(())
} }
pub fn update_topic_description(&self, topic_id: &str, description: &str) -> Result<(), StorageError> { pub fn update_topic_description(
&self,
topic_id: &str,
description: &str,
) -> Result<(), StorageError> {
let now = current_timestamp(); let now = current_timestamp();
let conn = self.pool.get()?; let conn = self.pool.get()?;
conn.execute( conn.execute(
@ -810,12 +815,7 @@ impl SessionStore {
archived_at = NULL archived_at = NULL
WHERE id = ?1 AND deleted_at IS NULL WHERE id = ?1 AND deleted_at IS NULL
", ",
params![ params![session_id, inserted_count, active_user_turn_count, now,],
session_id,
inserted_count,
active_user_turn_count,
now,
],
)?; )?;
tx.commit()?; tx.commit()?;
@ -1583,7 +1583,8 @@ impl SessionStore {
/// 获取指定话题的消息数量(动态计算,确保准确) /// 获取指定话题的消息数量(动态计算,确保准确)
pub fn get_topic_message_count(&self, topic_id: &str) -> Result<usize, StorageError> { pub fn get_topic_message_count(&self, topic_id: &str) -> Result<usize, StorageError> {
self.load_messages_for_topic(topic_id, None).map(|msgs| msgs.len()) self.load_messages_for_topic(topic_id, None)
.map(|msgs| msgs.len())
} }
pub fn load_all_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> { pub fn load_all_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> {
@ -1619,10 +1620,7 @@ impl SessionStore {
let now = current_timestamp(); let now = current_timestamp();
// Delete existing todos for this scope_key // Delete existing todos for this scope_key
tx.execute( tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?;
"DELETE FROM todos WHERE scope_key = ?1",
params![scope_key],
)?;
// Insert new todos // Insert new todos
for item in items { for item in items {

View File

@ -8,18 +8,38 @@ pub const GLOBAL_SCOPE_KEY: &str = "default";
/// 每个命名空间代表一类记忆内容,用于分类管理和检索。 /// 每个命名空间代表一类记忆内容,用于分类管理和检索。
/// 禁止使用未在此列表中的 namespace 创建记忆。 /// 禁止使用未在此列表中的 namespace 创建记忆。
pub const ALLOWED_MEMORY_NAMESPACES: &[(&str, &str)] = &[ pub const ALLOWED_MEMORY_NAMESPACES: &[(&str, &str)] = &[
("user", "用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作"), (
("semantic", "语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累"), "user",
("episodic", "情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪"), "用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作",
("skill", "技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行"), ),
("environment", "环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力"), (
("reflection", "反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进"), "semantic",
"语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累",
),
(
"episodic",
"情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪",
),
(
"skill",
"技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行",
),
(
"environment",
"环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力",
),
(
"reflection",
"反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进",
),
("other", "其他记忆:不属于以上分类的其他记忆内容"), ("other", "其他记忆:不属于以上分类的其他记忆内容"),
]; ];
/// 验证 namespace 是否在允许列表中 /// 验证 namespace 是否在允许列表中
pub fn is_valid_namespace(namespace: &str) -> bool { pub fn is_valid_namespace(namespace: &str) -> bool {
ALLOWED_MEMORY_NAMESPACES.iter().any(|(name, _)| *name == namespace) ALLOWED_MEMORY_NAMESPACES
.iter()
.any(|(name, _)| *name == namespace)
} }
/// 获取 namespace 的中文描述 /// 获取 namespace 的中文描述
@ -32,7 +52,10 @@ pub fn get_namespace_description(namespace: &str) -> Option<&'static str> {
/// 获取所有允许的 namespace 名称列表(用于 JSON schema enum /// 获取所有允许的 namespace 名称列表(用于 JSON schema enum
pub fn allowed_namespace_names() -> Vec<&'static str> { pub fn allowed_namespace_names() -> Vec<&'static str> {
ALLOWED_MEMORY_NAMESPACES.iter().map(|(name, _)| *name).collect() ALLOWED_MEMORY_NAMESPACES
.iter()
.map(|(name, _)| *name)
.collect()
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@ -97,7 +97,9 @@ pub(super) fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<Se
}) })
} }
pub(super) fn map_skill_event_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillEventRecord> { pub(super) fn map_skill_event_record(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<SkillEventRecord> {
let payload_json: String = row.get(4)?; let payload_json: String = row.get(4)?;
let payload = serde_json::from_str(&payload_json).map_err(|err| { let payload = serde_json::from_str(&payload_json).map_err(|err| {
rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err)) rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err))
@ -129,11 +131,7 @@ pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<
.map(serde_json::from_str) .map(serde_json::from_str)
.transpose() .transpose()
.map_err(|err| { .map_err(|err| {
rusqlite::Error::FromSqlConversionFailure( rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(err))
9,
rusqlite::types::Type::Text,
Box::new(err),
)
})?; })?;
Ok(ChatMessage { Ok(ChatMessage {

View File

@ -1,5 +1,5 @@
use super::*;
use super::migrations::has_column; use super::migrations::has_column;
use super::*;
use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT; use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT;
use crate::domain::messages::ToolCall; use crate::domain::messages::ToolCall;
@ -10,10 +10,19 @@ fn test_persistent_session_id_for_cli_and_channel() {
assert_eq!(persistent_session_id("cli", "abc"), "abc"); assert_eq!(persistent_session_id("cli", "abc"), "abc");
// 幂等:已带前缀的 chat_id 会被清理,不会累积前缀 // 幂等:已带前缀的 chat_id 会被清理,不会累积前缀
assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc"); assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc");
assert_eq!(persistent_session_id("websocket", "websocket:websocket:abc"), "abc"); assert_eq!(
assert_eq!(persistent_session_id(TEST_CHANNEL, "abc"), "test-channel:abc"); persistent_session_id("websocket", "websocket:websocket:abc"),
"abc"
);
assert_eq!(
persistent_session_id(TEST_CHANNEL, "abc"),
"test-channel:abc"
);
// 其他通道也幂等 // 其他通道也幂等
assert_eq!(persistent_session_id(TEST_CHANNEL, "test-channel:abc"), "test-channel:abc"); assert_eq!(
persistent_session_id(TEST_CHANNEL, "test-channel:abc"),
"test-channel:abc"
);
} }
#[test] #[test]
@ -76,8 +85,12 @@ fn test_session_store_roundtrip_and_lifecycle() {
fn test_ensure_channel_session_is_stable() { fn test_ensure_channel_session_is_stable() {
let store = SessionStore::in_memory().unwrap(); let store = SessionStore::in_memory().unwrap();
let first = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); let first = store
let second = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); .ensure_channel_session(TEST_CHANNEL, "chat-1")
.unwrap();
let second = store
.ensure_channel_session(TEST_CHANNEL, "chat-1")
.unwrap();
assert_eq!(first.id, second.id); assert_eq!(first.id, second.id);
assert_eq!(first.chat_id, "chat-1"); assert_eq!(first.chat_id, "chat-1");
@ -176,8 +189,7 @@ fn test_schema_migration_adds_user_turn_and_reinjection_columns() {
#[test] #[test]
fn test_schema_migration_adds_reasoning_content_column_to_messages() { fn test_schema_migration_adds_reasoning_content_column_to_messages() {
let tmp = std::env::temp_dir() let tmp = std::env::temp_dir().join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4()));
.join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4()));
let conn = Connection::open(&tmp).unwrap(); let conn = Connection::open(&tmp).unwrap();
conn.execute_batch( conn.execute_batch(
" "
@ -225,10 +237,8 @@ fn test_compact_active_history_rebuilds_active_segment_with_delta_messages() {
let store = SessionStore::in_memory().unwrap(); let store = SessionStore::in_memory().unwrap();
let session = store.create_cli_session(Some("compact-history")).unwrap(); let session = store.create_cli_session(Some("compact-history")).unwrap();
let agent_prompt = ChatMessage::system_with_context( let agent_prompt =
"agent", ChatMessage::system_with_context("agent", Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()));
Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()),
);
let seed_messages = vec![ let seed_messages = vec![
agent_prompt.clone(), agent_prompt.clone(),
ChatMessage::user("u1"), ChatMessage::user("u1"),
@ -378,7 +388,10 @@ fn test_memory_roundtrip_with_source_fields() {
assert_eq!(saved.content, "Rust"); assert_eq!(saved.content, "Rust");
assert_eq!(saved.source_type, "message"); assert_eq!(saved.source_type, "message");
assert_eq!(saved.source_session_id.as_deref(), Some("test-channel:chat-1")); assert_eq!(
saved.source_session_id.as_deref(),
Some("test-channel:chat-1")
);
assert_eq!(saved.source_message_id.as_deref(), Some("msg-1")); assert_eq!(saved.source_message_id.as_deref(), Some("msg-1"));
assert_eq!(saved.source_message_seq, Some(7)); assert_eq!(saved.source_message_seq, Some(7));
@ -474,7 +487,13 @@ fn test_memory_search_matches_memory_key_field() {
.unwrap(); .unwrap();
let hits = store let hits = store
.search_memories("user", "test-channel:user-1", "email_folder_preference", None, 10) .search_memories(
"user",
"test-channel:user-1",
"email_folder_preference",
None,
10,
)
.unwrap(); .unwrap();
assert_eq!(hits.len(), 1); assert_eq!(hits.len(), 1);
@ -585,7 +604,10 @@ fn test_memory_scope_listing_and_full_scope_read() {
let scope_keys = store.list_memory_scope_keys("user").unwrap(); let scope_keys = store.list_memory_scope_keys("user").unwrap();
assert_eq!( assert_eq!(
scope_keys, scope_keys,
vec!["test-channel:user-1".to_string(), "test-channel:user-2".to_string()] vec![
"test-channel:user-1".to_string(),
"test-channel:user-2".to_string()
]
); );
let full_scope = store let full_scope = store

View File

@ -13,7 +13,7 @@ use tokio::time::{Instant, sleep_until};
use crate::platform::{ShellInfo, dangerous_command_patterns}; use crate::platform::{ShellInfo, dangerous_command_patterns};
use crate::tools::shell_session::ShellSessionManager; use crate::tools::shell_session::ShellSessionManager;
use crate::tools::traits::{Tool, ToolResult}; use crate::tools::traits::{Tool, ToolResult};
use crate::tools::{extract_u64, extract_bool, check_null_args}; use crate::tools::{check_null_args, extract_bool, extract_u64};
const MAX_TIMEOUT_SECS: u64 = 600; const MAX_TIMEOUT_SECS: u64 = 600;
const MAX_OUTPUT_CHARS: usize = 50_000; const MAX_OUTPUT_CHARS: usize = 50_000;
@ -84,7 +84,11 @@ impl ShellKind {
/// 执行命令所需的参数 /// 执行命令所需的参数
pub fn command_args<'a>(&self, command: &'a str) -> Vec<&'a str> { pub fn command_args<'a>(&self, command: &'a str) -> Vec<&'a str> {
let info = self.to_info(); let info = self.to_info();
info.args.iter().map(|s| *s).chain(std::iter::once(command)).collect() info.args
.iter()
.map(|s| *s)
.chain(std::iter::once(command))
.collect()
} }
/// 工具名称 /// 工具名称
@ -95,9 +99,15 @@ impl ShellKind {
/// 工具描述 /// 工具描述
pub fn tool_description(&self) -> &'static str { pub fn tool_description(&self) -> &'static str {
match self { match self {
ShellKind::Bash => "Execute a bash shell command and return its output. Use with caution.", ShellKind::Bash => {
ShellKind::PowerShell => "Execute a PowerShell command and return its output. Use with caution.", "Execute a bash shell command and return its output. Use with caution."
ShellKind::Cmd => "Execute a cmd shell command and return its output. Use with caution.", }
ShellKind::PowerShell => {
"Execute a PowerShell command and return its output. Use with caution."
}
ShellKind::Cmd => {
"Execute a cmd shell command and return its output. Use with caution."
}
} }
} }
} }
@ -189,10 +199,7 @@ impl BashTool {
}; };
format!( format!(
"{}\n{}{}\n\n{}", "{}\n{}{}\n\n{}",
PENDING_USER_ACTION_MARKER, PENDING_USER_ACTION_MARKER, session_line, hint, output_section
session_line,
hint,
output_section
) )
} }
@ -711,10 +718,7 @@ mod tests {
} else { } else {
"echo 'Hello World'" "echo 'Hello World'"
}; };
let result = tool let result = tool.execute(json!({ "command": command })).await.unwrap();
.execute(json!({ "command": command }))
.await
.unwrap();
assert!(result.success); assert!(result.success);
assert!(result.output.contains("Hello World")); assert!(result.output.contains("Hello World"));
@ -742,10 +746,7 @@ mod tests {
} else { } else {
format!("ls -la {}", temp_dir.display()) format!("ls -la {}", temp_dir.display())
}; };
let result = tool let result = tool.execute(json!({ "command": command })).await.unwrap();
.execute(json!({ "command": command }))
.await
.unwrap();
assert!(result.success); assert!(result.success);
} }
@ -892,8 +893,17 @@ mod tests {
#[test] #[test]
fn test_shell_kind_command_args() { fn test_shell_kind_command_args() {
assert_eq!(ShellKind::Bash.command_args("echo hello"), vec!["-c" as &str, "echo hello"]); assert_eq!(
assert_eq!(ShellKind::PowerShell.command_args("echo hello"), vec!["-Command" as &str, "echo hello"]); ShellKind::Bash.command_args("echo hello"),
assert_eq!(ShellKind::Cmd.command_args("echo hello"), vec!["/C" as &str, "echo hello"]); vec!["-c" as &str, "echo hello"]
);
assert_eq!(
ShellKind::PowerShell.command_args("echo hello"),
vec!["-Command" as &str, "echo hello"]
);
assert_eq!(
ShellKind::Cmd.command_args("echo hello"),
vec!["/C" as &str, "echo hello"]
);
} }
} }

View File

@ -1,6 +1,6 @@
use super::traits::{Tool, ToolResult}; use super::traits::{Tool, ToolResult};
use crate::tools::extract_f64 as extract_f64_opt;
use crate::tools::check_null_args; use crate::tools::check_null_args;
use crate::tools::extract_f64 as extract_f64_opt;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::json; use serde_json::json;
@ -161,7 +161,8 @@ fn extract_f64(args: &serde_json::Value, key: &str, name: &str) -> Result<f64, S
if let Some(n) = v.as_f64() { if let Some(n) = v.as_f64() {
Ok(n) Ok(n)
} else if let Some(s) = v.as_str() { } else if let Some(s) = v.as_str() {
s.parse::<f64>().map_err(|_| format!("{name} is not a valid number: {s}")) s.parse::<f64>()
.map_err(|_| format!("{name} is not a valid number: {s}"))
} else { } else {
Err(format!("{name} must be a number")) Err(format!("{name} must be a number"))
} }
@ -176,7 +177,8 @@ fn extract_i64(args: &serde_json::Value, key: &str, name: &str) -> Result<i64, S
if let Some(n) = v.as_i64() { if let Some(n) = v.as_i64() {
Ok(n) Ok(n)
} else if let Some(s) = v.as_str() { } else if let Some(s) = v.as_str() {
s.parse::<i64>().map_err(|_| format!("{name} is not a valid integer: {s}")) s.parse::<i64>()
.map_err(|_| format!("{name} is not a valid integer: {s}"))
} else { } else {
Err(format!("{name} must be an integer")) Err(format!("{name} must be an integer"))
} }
@ -755,7 +757,13 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert!(!result.success); assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("x is not a valid number")); assert!(
result
.error
.as_ref()
.unwrap()
.contains("x is not a valid number")
);
} }
#[tokio::test] #[tokio::test]
@ -763,6 +771,12 @@ mod tests {
let tool = CalculatorTool::new(); let tool = CalculatorTool::new();
let result = tool.execute(serde_json::Value::Null).await.unwrap(); let result = tool.execute(serde_json::Value::Null).await.unwrap();
assert!(!result.success); assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("Missing required parameters")); assert!(
result
.error
.as_ref()
.unwrap()
.contains("Missing required parameters")
);
} }
} }

View File

@ -3,8 +3,8 @@ use std::path::Path;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::json; use serde_json::json;
use crate::tools::traits::{Tool, ToolResult};
use crate::tools::extract_bool; use crate::tools::extract_bool;
use crate::tools::traits::{Tool, ToolResult};
pub struct FileEditTool { pub struct FileEditTool {
allowed_dir: Option<String>, allowed_dir: Option<String>,
@ -43,21 +43,28 @@ impl FileEditTool {
Ok(c) => c, Ok(c) => c,
Err(_) => { Err(_) => {
// File doesn't exist yet; canonicalize parent directory // File doesn't exist yet; canonicalize parent directory
let parent = resolved.parent().ok_or_else(|| { let parent = resolved
format!("Path '{}' has no parent directory", path) .parent()
})?; .ok_or_else(|| format!("Path '{}' has no parent directory", path))?;
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| { let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
format!("Failed to canonicalize parent directory of '{}': {}", path, e) format!(
"Failed to canonicalize parent directory of '{}': {}",
path, e
)
})?; })?;
canonical_parent.join(resolved.file_name().ok_or_else(|| { canonical_parent.join(
format!("Path '{}' has no file name component", path) resolved
})?) .file_name()
.ok_or_else(|| format!("Path '{}' has no file name component", path))?,
)
} }
}; };
if !canonical_resolved.starts_with(&canonical_allowed) { if !canonical_resolved.starts_with(&canonical_allowed) {
return Err(format!( return Err(format!(
"Path '{}' (resolves to '{}') is outside allowed directory '{}'", "Path '{}' (resolves to '{}') is outside allowed directory '{}'",
path, canonical_resolved.display(), canonical_allowed.display() path,
canonical_resolved.display(),
canonical_allowed.display()
)); ));
} }
} }

View File

@ -4,8 +4,8 @@ use async_trait::async_trait;
use serde_json::json; use serde_json::json;
use crate::text::take_prefix_chars; use crate::text::take_prefix_chars;
use crate::tools::traits::{Tool, ToolResult};
use crate::tools::extract_u64; use crate::tools::extract_u64;
use crate::tools::traits::{Tool, ToolResult};
const MAX_CHARS: usize = 100_000; const MAX_CHARS: usize = 100_000;
const DEFAULT_LIMIT: usize = 2000; const DEFAULT_LIMIT: usize = 2000;
@ -48,7 +48,9 @@ impl FileReadTool {
if !canonical_resolved.starts_with(&canonical_allowed) { if !canonical_resolved.starts_with(&canonical_allowed) {
return Err(format!( return Err(format!(
"Path '{}' (resolves to '{}') is outside allowed directory '{}'", "Path '{}' (resolves to '{}') is outside allowed directory '{}'",
path, canonical_resolved.display(), canonical_allowed.display() path,
canonical_resolved.display(),
canonical_allowed.display()
)); ));
} }
} }

View File

@ -42,21 +42,28 @@ impl FileWriteTool {
Ok(c) => c, Ok(c) => c,
Err(_) => { Err(_) => {
// File doesn't exist yet; canonicalize parent directory // File doesn't exist yet; canonicalize parent directory
let parent = resolved.parent().ok_or_else(|| { let parent = resolved
format!("Path '{}' has no parent directory", path) .parent()
})?; .ok_or_else(|| format!("Path '{}' has no parent directory", path))?;
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| { let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
format!("Failed to canonicalize parent directory of '{}': {}", path, e) format!(
"Failed to canonicalize parent directory of '{}': {}",
path, e
)
})?; })?;
canonical_parent.join(resolved.file_name().ok_or_else(|| { canonical_parent.join(
format!("Path '{}' has no file name component", path) resolved
})?) .file_name()
.ok_or_else(|| format!("Path '{}' has no file name component", path))?,
)
} }
}; };
if !canonical_resolved.starts_with(&canonical_allowed) { if !canonical_resolved.starts_with(&canonical_allowed) {
return Err(format!( return Err(format!(
"Path '{}' (resolves to '{}') is outside allowed directory '{}'", "Path '{}' (resolves to '{}') is outside allowed directory '{}'",
path, canonical_resolved.display(), canonical_allowed.display() path,
canonical_resolved.display(),
canonical_allowed.display()
)); ));
} }
} }

View File

@ -3,7 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::json; use serde_json::json;
use crate::storage::{is_valid_namespace, MemoryRecord, MemoryRepository, MemoryUpsert}; use crate::storage::{MemoryRecord, MemoryRepository, MemoryUpsert, is_valid_namespace};
use crate::tools::traits::{Tool, ToolContext, ToolResult}; use crate::tools::traits::{Tool, ToolContext, ToolResult};
pub struct MemoryManageTool { pub struct MemoryManageTool {

View File

@ -4,8 +4,8 @@ use async_trait::async_trait;
use serde_json::json; use serde_json::json;
use crate::storage::{MemoryRecord, MemoryRepository}; use crate::storage::{MemoryRecord, MemoryRepository};
use crate::tools::traits::{Tool, ToolContext, ToolResult};
use crate::tools::extract_u64; use crate::tools::extract_u64;
use crate::tools::traits::{Tool, ToolContext, ToolResult};
pub struct MemorySearchTool { pub struct MemorySearchTool {
memories: Arc<dyn MemoryRepository>, memories: Arc<dyn MemoryRepository>,
@ -103,8 +103,7 @@ impl Tool for MemorySearchTool {
Some(value) => { Some(value) => {
// 支持两种格式:实际数组 或 字符串化的数组 // 支持两种格式:实际数组 或 字符串化的数组
if let Some(arr) = value.as_array() { if let Some(arr) = value.as_array() {
arr arr.iter()
.iter()
.filter_map(|v| v.as_str()) .filter_map(|v| v.as_str())
.map(str::trim) .map(str::trim)
.filter(|v| !v.is_empty()) .filter(|v| !v.is_empty())
@ -133,7 +132,7 @@ impl Tool for MemorySearchTool {
vec![] vec![]
} }
} }
None => vec![] None => vec![],
}; };
if queries.is_empty() { if queries.is_empty() {
return Ok(error_result("Missing required parameter: queries")); return Ok(error_result("Missing required parameter: queries"));

View File

@ -8,8 +8,8 @@ pub mod memory_manage;
pub mod memory_search; pub mod memory_search;
pub mod registry; pub mod registry;
pub mod scheduler_manage; pub mod scheduler_manage;
pub mod session_send;
pub mod schema; pub mod schema;
pub mod session_send;
pub mod shell_session; pub mod shell_session;
pub mod skill_activate; pub mod skill_activate;
pub mod skill_manage; pub mod skill_manage;
@ -30,11 +30,11 @@ pub use memory_manage::MemoryManageTool;
pub use memory_search::MemorySearchTool; pub use memory_search::MemorySearchTool;
pub use registry::ToolRegistry; pub use registry::ToolRegistry;
pub use scheduler_manage::SchedulerManageTool; pub use scheduler_manage::SchedulerManageTool;
pub use schema::{CleaningStrategy, SchemaCleanr};
pub use session_send::{ pub use session_send::{
NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest, NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest,
SessionSendTool, SessionSendTool,
}; };
pub use schema::{CleaningStrategy, SchemaCleanr};
pub use shell_session::ShellSessionManager; pub use shell_session::ShellSessionManager;
pub use skill_activate::SkillActivateTool; pub use skill_activate::SkillActivateTool;
pub use skill_manage::SkillManageTool; pub use skill_manage::SkillManageTool;
@ -127,26 +127,22 @@ pub fn require_string(args: &serde_json::Value, key: &str) -> Result<String, Str
/// Extract a required f64 parameter, returning an error message if missing. /// Extract a required f64 parameter, returning an error message if missing.
pub fn require_f64(args: &serde_json::Value, key: &str) -> Result<f64, String> { pub fn require_f64(args: &serde_json::Value, key: &str) -> Result<f64, String> {
extract_f64(args, key) extract_f64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
.ok_or_else(|| format!("Missing required parameter: {}", key))
} }
/// Extract a required i64 parameter, returning an error message if missing. /// Extract a required i64 parameter, returning an error message if missing.
pub fn require_i64(args: &serde_json::Value, key: &str) -> Result<i64, String> { pub fn require_i64(args: &serde_json::Value, key: &str) -> Result<i64, String> {
extract_i64(args, key) extract_i64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
.ok_or_else(|| format!("Missing required parameter: {}", key))
} }
/// Extract a required u64 parameter, returning an error message if missing. /// Extract a required u64 parameter, returning an error message if missing.
pub fn require_u64(args: &serde_json::Value, key: &str) -> Result<u64, String> { pub fn require_u64(args: &serde_json::Value, key: &str) -> Result<u64, String> {
extract_u64(args, key) extract_u64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
.ok_or_else(|| format!("Missing required parameter: {}", key))
} }
/// Extract a required bool parameter, returning an error message if missing. /// Extract a required bool parameter, returning an error message if missing.
pub fn require_bool(args: &serde_json::Value, key: &str) -> Result<bool, String> { pub fn require_bool(args: &serde_json::Value, key: &str) -> Result<bool, String> {
extract_bool(args, key) extract_bool(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
.ok_or_else(|| format!("Missing required parameter: {}", key))
} }
/// Extract a string array parameter, handling both actual arrays and stringified JSON arrays. /// Extract a string array parameter, handling both actual arrays and stringified JSON arrays.
@ -207,7 +203,13 @@ pub fn check_null_args(args: &serde_json::Value, tool_name: &str) -> Option<Tool
error: Some(format!( error: Some(format!(
"Invalid parameters: {} expects a JSON object, got {}", "Invalid parameters: {} expects a JSON object, got {}",
tool_name, tool_name,
if args.is_array() { "an array" } else if args.is_string() { "a string" } else { "an unexpected type" } if args.is_array() {
"an array"
} else if args.is_string() {
"a string"
} else {
"an unexpected type"
}
)), )),
}); });
} }

View File

@ -59,7 +59,8 @@ impl ToolRegistry {
} }
pub fn has_tools(&self) -> bool { pub fn has_tools(&self) -> bool {
!self.tools !self
.tools
.read() .read()
.expect("ToolRegistry lock poisoned") .expect("ToolRegistry lock poisoned")
.is_empty() .is_empty()
@ -84,7 +85,10 @@ impl ToolRegistry {
.map(|(k, v)| (k.clone(), v.clone())) .map(|(k, v)| (k.clone(), v.clone()))
.collect(); .collect();
let new_registry = ToolRegistry::new(); let new_registry = ToolRegistry::new();
*new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered; *new_registry
.tools
.write()
.expect("ToolRegistry lock poisoned") = filtered;
new_registry new_registry
} }
@ -99,7 +103,10 @@ impl ToolRegistry {
.map(|(k, v)| (k.clone(), v.clone())) .map(|(k, v)| (k.clone(), v.clone()))
.collect(); .collect();
let new_registry = ToolRegistry::new(); let new_registry = ToolRegistry::new();
*new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered; *new_registry
.tools
.write()
.expect("ToolRegistry lock poisoned") = filtered;
new_registry new_registry
} }
} }

View File

@ -132,13 +132,7 @@ impl Tool for SessionSendTool {
let outcome = match self let outcome = match self
.sender .sender
.send_to_current_session( .send_to_current_session(context, SessionSendRequest { text, attachments })
context,
SessionSendRequest {
text,
attachments,
},
)
.await .await
{ {
Ok(outcome) => outcome, Ok(outcome) => outcome,
@ -154,7 +148,12 @@ impl Tool for SessionSendTool {
} }
fn validate_context(context: &ToolContext) -> anyhow::Result<()> { fn validate_context(context: &ToolContext) -> anyhow::Result<()> {
if context.channel_name.as_deref().unwrap_or_default().is_empty() { if context
.channel_name
.as_deref()
.unwrap_or_default()
.is_empty()
{
return Err(anyhow!( return Err(anyhow!(
"send_session_message requires channel_name in tool context" "send_session_message requires channel_name in tool context"
)); ));
@ -413,8 +412,7 @@ fn filename_matches_target(on_disk_name: &std::ffi::OsStr, target: &str) -> bool
fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>> { fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>> {
// 支持两种格式:实际数组 或 字符串化的 JSON 数组 // 支持两种格式:实际数组 或 字符串化的 JSON 数组
let paths = if let Some(arr) = value.as_array() { let paths = if let Some(arr) = value.as_array() {
arr arr.iter()
.iter()
.filter_map(|v| v.as_str()) .filter_map(|v| v.as_str())
.map(str::trim) .map(str::trim)
.filter(|v| !v.is_empty()) .filter(|v| !v.is_empty())
@ -565,7 +563,10 @@ mod tests {
.unwrap(); .unwrap();
assert!(result.success); assert!(result.success);
assert_eq!(result.output, "Sent 1 text message to the current conversation."); assert_eq!(
result.output,
"Sent 1 text message to the current conversation."
);
} }
#[tokio::test] #[tokio::test]
@ -597,8 +598,8 @@ mod tests {
let image_path = file.path().with_extension("png"); let image_path = file.path().with_extension("png");
std::fs::rename(file.path(), &image_path).unwrap(); std::fs::rename(file.path(), &image_path).unwrap();
let attachments = parse_attachments(&json!([image_path.to_string_lossy().to_string()])) let attachments =
.unwrap(); parse_attachments(&json!([image_path.to_string_lossy().to_string()])).unwrap();
assert_eq!(attachments.len(), 1); assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].media_type, "image"); assert_eq!(attachments[0].media_type, "image");

View File

@ -5,8 +5,8 @@ use serde_json::json;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::storage::SkillEventRepository; use crate::storage::SkillEventRepository;
use crate::tools::traits::{Tool, ToolContext, ToolResult};
use crate::tools::check_null_args; use crate::tools::check_null_args;
use crate::tools::traits::{Tool, ToolContext, ToolResult};
pub struct SkillActivateTool { pub struct SkillActivateTool {
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
@ -135,7 +135,9 @@ mod tests {
async fn test_skill_activate_records_failed_activation_event() { async fn test_skill_activate_records_failed_activation_event() {
let skills = Arc::new(SkillRuntime::default()); let skills = Arc::new(SkillRuntime::default());
let store = Arc::new(SessionStore::in_memory().unwrap()); let store = Arc::new(SessionStore::in_memory().unwrap());
store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); store
.ensure_channel_session(TEST_CHANNEL, "chat-1")
.unwrap();
let tool = SkillActivateTool::new(skills, store.clone()); let tool = SkillActivateTool::new(skills, store.clone());
let context = ToolContext { let context = ToolContext {
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
@ -162,7 +164,9 @@ mod tests {
async fn test_skill_activate_handles_null_args() { async fn test_skill_activate_handles_null_args() {
let skills = Arc::new(SkillRuntime::default()); let skills = Arc::new(SkillRuntime::default());
let store = Arc::new(SessionStore::in_memory().unwrap()); let store = Arc::new(SessionStore::in_memory().unwrap());
store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap(); store
.ensure_channel_session(TEST_CHANNEL, "chat-1")
.unwrap();
let tool = SkillActivateTool::new(skills, store.clone()); let tool = SkillActivateTool::new(skills, store.clone());
let context = ToolContext { let context = ToolContext {
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
@ -175,6 +179,11 @@ mod tests {
.unwrap(); .unwrap();
assert!(!result.success); assert!(!result.success);
assert!(result.error.unwrap().contains("Missing required parameters")); assert!(
result
.error
.unwrap()
.contains("Missing required parameters")
);
} }
} }

View File

@ -8,6 +8,12 @@ pub mod types;
pub use error::TaskError; pub use error::TaskError;
pub use prompt::SubagentPromptBuilder; pub use prompt::SubagentPromptBuilder;
pub use repository::{InMemoryTaskRepository, TaskRepository}; pub use repository::{InMemoryTaskRepository, TaskRepository};
pub use runtime::{DefaultSubAgentRuntime, SubAgentRuntime, SubAgentRuntimeConfig, SubagentCatalog, StaticSystemPromptProvider}; pub use runtime::{
DefaultSubAgentRuntime, StaticSystemPromptProvider, SubAgentRuntime, SubAgentRuntimeConfig,
SubagentCatalog,
};
pub use tool::TaskTool; pub use tool::TaskTool;
pub use types::{SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession, TaskSessionState, TaskToolArgs, TaskToolResult}; pub use types::{
SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession,
TaskSessionState, TaskToolArgs, TaskToolResult,
};

View File

@ -7,20 +7,23 @@ use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use serde::Deserialize; use serde::Deserialize;
use crate::agent::{AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::agent::{
AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler,
SystemPrompt, SystemPromptContext, SystemPromptProvider,
};
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
use crate::bus::message::{OutboundMessage, OutboundEventKind};
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::domain::CapabilityPolicy; use crate::bus::message::{OutboundEventKind, OutboundMessage};
use crate::providers::StreamDelta;
use crate::config::{LLMProviderConfig, SubagentsConfig}; use crate::config::{LLMProviderConfig, SubagentsConfig};
use crate::domain::CapabilityPolicy;
use crate::experts::ExpertRuntime; use crate::experts::ExpertRuntime;
use crate::providers::StreamDelta;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::storage::{ConversationRepository, SessionStore}; use crate::storage::{ConversationRepository, SessionStore};
use crate::tools::{ToolContext, ToolRegistry}; use crate::tools::{ToolContext, ToolRegistry};
use super::error::TaskError; use super::error::TaskError;
use super::prompt::{extract_summary, SubagentPromptBuilder}; use super::prompt::{SubagentPromptBuilder, extract_summary};
use super::repository::TaskRepository; use super::repository::TaskRepository;
use super::tool::TaskTool; use super::tool::TaskTool;
use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult}; use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult};
@ -167,7 +170,9 @@ impl EmittedMessageHandler for SubAgentEmitter {
async fn handle_stream_delta(&self, delta: &StreamDelta) { async fn handle_stream_delta(&self, delta: &StreamDelta) {
let message_id = { let message_id = {
let mut guard = self.stream_message_id.lock().unwrap(); let mut guard = self.stream_message_id.lock().unwrap();
guard.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone() guard
.get_or_insert_with(|| uuid::Uuid::new_v4().to_string())
.clone()
}; };
let outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() { let outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() {
@ -289,10 +294,7 @@ fn build_subagent_event_metadata(session: &TaskSession) -> HashMap<String, Strin
} }
/// 发布子智能体执行完成事件ExecutionCompletedmetadata 含 subagent_task_id。 /// 发布子智能体执行完成事件ExecutionCompletedmetadata 含 subagent_task_id。
async fn publish_subagent_completion( async fn publish_subagent_completion(bus: &Option<Arc<MessageBus>>, session: &TaskSession) {
bus: &Option<Arc<MessageBus>>,
session: &TaskSession,
) {
if let Some(bus) = bus { if let Some(bus) = bus {
let metadata = build_subagent_event_metadata(session); let metadata = build_subagent_event_metadata(session);
if let Err(e) = bus if let Err(e) = bus
@ -475,17 +477,20 @@ impl DefaultSubAgentRuntime {
// 按 def 中的 provider/model 字段解析覆盖基础 provider_config。 // 按 def 中的 provider/model 字段解析覆盖基础 provider_config。
// 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。 // 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。
let effective_provider_config = match def { let effective_provider_config = match def {
Some(d) if d.provider.is_some() || d.model.is_some() => { Some(d) if d.provider.is_some() || d.model.is_some() => self
self.model_resolver .model_resolver
.resolve(d.provider.as_deref(), d.model.as_deref(), &self.provider_config) .resolve(
d.provider.as_deref(),
d.model.as_deref(),
&self.provider_config,
)
.map_err(|e| { .map_err(|e| {
TaskError::AgentCreationFailed(format!( TaskError::AgentCreationFailed(format!(
"subagent '{}' model resolution failed: {}", "subagent '{}' model resolution failed: {}",
def.map(|d| d.name.as_str()).unwrap_or("?"), def.map(|d| d.name.as_str()).unwrap_or("?"),
e e
)) ))
})? })?,
}
_ => self.provider_config.clone(), _ => self.provider_config.clone(),
}; };
@ -519,7 +524,10 @@ impl DefaultSubAgentRuntime {
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata.insert("subagent_task_id".to_string(), session.id.clone()); metadata.insert("subagent_task_id".to_string(), session.id.clone());
metadata.insert("is_subagent_event".to_string(), "true".to_string()); metadata.insert("is_subagent_event".to_string(), "true".to_string());
metadata.insert("topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default()); metadata.insert(
"topic_id".to_string(),
session.parent_topic_id.clone().unwrap_or_default(),
);
let emitter = Arc::new(PersistingEmittedMessageHandler::new( let emitter = Arc::new(PersistingEmittedMessageHandler::new(
SubAgentEmitter { SubAgentEmitter {
@ -703,8 +711,14 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata.insert("task_id".to_string(), session.id.clone()); metadata.insert("task_id".to_string(), session.id.clone());
metadata.insert("task_description".to_string(), session.description.clone()); metadata.insert("task_description".to_string(), session.description.clone());
metadata.insert("task_subagent_type".to_string(), session.subagent_type.clone()); metadata.insert(
metadata.insert("topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default()); "task_subagent_type".to_string(),
session.subagent_type.clone(),
);
metadata.insert(
"topic_id".to_string(),
session.parent_topic_id.clone().unwrap_or_default(),
);
// 如果是子智能体创建的孙智能体,传递父 task_id // 如果是子智能体创建的孙智能体,传递父 task_id
if let Some(ref ptid) = parent_context.task_id { if let Some(ref ptid) = parent_context.task_id {
@ -752,11 +766,17 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) { let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) {
(true, _) | (_, true) => self (true, _) | (_, true) => self
.model_resolver .model_resolver
.resolve(def.provider.as_deref(), def.model.as_deref(), &self.provider_config) .resolve(
.map_err(|e| TaskError::AgentCreationFailed(format!( def.provider.as_deref(),
def.model.as_deref(),
&self.provider_config,
)
.map_err(|e| {
TaskError::AgentCreationFailed(format!(
"subagent '{}' model resolution failed: {}", "subagent '{}' model resolution failed: {}",
def.name, e def.name, e
)))?, ))
})?,
_ => self.provider_config.clone(), _ => self.provider_config.clone(),
}; };
let system_prompt = SubagentPromptBuilder::build( let system_prompt = SubagentPromptBuilder::build(
@ -768,7 +788,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
); );
// 7. 创建子代理 // 7. 创建子代理
let agent = self.create_subagent(&session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone())?; let agent = self.create_subagent(
&session,
system_prompt,
Some(&def),
parent_context.nesting_depth,
parent_context.task_id.clone(),
)?;
// 8. 执行任务 // 8. 执行任务
let result = self let result = self
@ -836,7 +862,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
} }
// 3. 确保 sessions 表中存在子智能体会话记录 // 3. 确保 sessions 表中存在子智能体会话记录
let session_title = format!("Subagent [{}]: {}", session.subagent_type, session.description); let session_title = format!(
"Subagent [{}]: {}",
session.subagent_type, session.description
);
if let Err(e) = self.conversation_repository.ensure_session( if let Err(e) = self.conversation_repository.ensure_session(
&session.session_id, &session.session_id,
&session.parent_channel_name, &session.parent_channel_name,
@ -847,10 +876,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
} }
// 4. 构建恢复提示词 // 4. 构建恢复提示词
let system_prompt = SubagentPromptBuilder::build_resume_prompt( let system_prompt =
&session.description, SubagentPromptBuilder::build_resume_prompt(&session.description, &additional_prompt);
&additional_prompt,
);
// 4.1 校验父智能体的子代理策略(白/黑名单)。 // 4.1 校验父智能体的子代理策略(白/黑名单)。
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许 // 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
@ -870,7 +897,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
.map_err(TaskError::InvalidArguments)?; .map_err(TaskError::InvalidArguments)?;
// 5. 创建子代理 // 5. 创建子代理
let agent = self.create_subagent(&session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone())?; let agent = self.create_subagent(
&session,
system_prompt,
Some(&def),
parent_context.nesting_depth,
parent_context.task_id.clone(),
)?;
// 6. 使用历史继续执行 // 6. 使用历史继续执行
let result = self let result = self
@ -901,7 +934,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
async fn send_message(&self, _task_id: &str, _message: String) -> Result<(), TaskError> { async fn send_message(&self, _task_id: &str, _message: String) -> Result<(), TaskError> {
// TODO: 实现双向通信 // TODO: 实现双向通信
// 需要在 TaskSession 中添加 pending_messages 队列 // 需要在 TaskSession 中添加 pending_messages 队列
Err(TaskError::InvalidArguments("send_message not implemented yet".to_string())) Err(TaskError::InvalidArguments(
"send_message not implemented yet".to_string(),
))
} }
async fn cleanup_expired(&self) -> Result<usize, TaskError> { async fn cleanup_expired(&self) -> Result<usize, TaskError> {
@ -945,7 +980,8 @@ impl SubagentCatalog {
fn discover_with_cwd(config: &SubagentsConfig, cwd: &Path) -> Self { fn discover_with_cwd(config: &SubagentsConfig, cwd: &Path) -> Self {
// 先内置作为基础 // 先内置作为基础
let mut merged: std::collections::HashMap<String, SubagentDef> = std::collections::HashMap::new(); let mut merged: std::collections::HashMap<String, SubagentDef> =
std::collections::HashMap::new();
merged.insert("general".to_string(), SubagentDef::builtin_general()); merged.insert("general".to_string(), SubagentDef::builtin_general());
tracing::debug!(cwd = %cwd.display(), "Discovering subagents from cwd"); tracing::debug!(cwd = %cwd.display(), "Discovering subagents from cwd");
@ -1023,7 +1059,7 @@ impl SubagentCatalog {
"# 子代理系统\n\n\ "# 子代理系统\n\n\
\n\ \n\
\n\n\ \n\n\
<available_subagents>\n" <available_subagents>\n",
); );
for def in defs { for def in defs {
@ -1223,15 +1259,24 @@ impl SubagentRuntime {
/// 重新发现子代理并替换内存 catalog写回 SUBAGENT.md 后调用)。 /// 重新发现子代理并替换内存 catalog写回 SUBAGENT.md 后调用)。
pub fn reload(&self) -> Result<(), String> { pub fn reload(&self) -> Result<(), String> {
let new_catalog = SubagentCatalog::discover(&self.config); let new_catalog = SubagentCatalog::discover(&self.config);
let mut guard = self.catalog.write().expect("subagent catalog rwlock poisoned"); let mut guard = self
.catalog
.write()
.expect("subagent catalog rwlock poisoned");
*guard = new_catalog; *guard = new_catalog;
Ok(()) Ok(())
} }
/// 列出所有子代理(含禁用项),带 disabled_in_scopes /// 列出所有子代理(含禁用项),带 disabled_in_scopes
pub fn list_with_status(&self) -> Vec<SubagentWithStatus> { pub fn list_with_status(&self) -> Vec<SubagentWithStatus> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned"); let state = self
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); .disable_state
.read()
.expect("subagent state rwlock poisoned");
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
let mut items: Vec<SubagentWithStatus> = catalog let mut items: Vec<SubagentWithStatus> = catalog
.all() .all()
.iter() .iter()
@ -1254,8 +1299,14 @@ impl SubagentRuntime {
/// 可用子代理名称(过滤禁用项) /// 可用子代理名称(过滤禁用项)
pub fn available_names(&self) -> Vec<String> { pub fn available_names(&self) -> Vec<String> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned"); let state = self
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); .disable_state
.read()
.expect("subagent state rwlock poisoned");
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
catalog catalog
.names() .names()
.into_iter() .into_iter()
@ -1265,17 +1316,30 @@ impl SubagentRuntime {
/// 查找可用子代理(过滤禁用项) /// 查找可用子代理(过滤禁用项)
pub fn find_available(&self, name: &str) -> Option<SubagentDef> { pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned"); let state = self
.disable_state
.read()
.expect("subagent state rwlock poisoned");
if state.is_disabled(name) { if state.is_disabled(name) {
return None; return None;
} }
self.catalog.read().expect("subagent catalog rwlock poisoned").find(name).cloned() self.catalog
.read()
.expect("subagent catalog rwlock poisoned")
.find(name)
.cloned()
} }
/// 生成过滤后的系统索引提示词 /// 生成过滤后的系统索引提示词
pub fn system_index_prompt_filtered(&self) -> Option<String> { pub fn system_index_prompt_filtered(&self) -> Option<String> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned"); let state = self
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); .disable_state
.read()
.expect("subagent state rwlock poisoned");
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
let available_defs: Vec<&SubagentDef> = catalog let available_defs: Vec<&SubagentDef> = catalog
.all() .all()
.into_iter() .into_iter()
@ -1313,8 +1377,14 @@ impl SubagentRuntime {
allowed: Option<&[String]>, allowed: Option<&[String]>,
denied: &[String], denied: &[String],
) -> Option<String> { ) -> Option<String> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned"); let state = self
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); .disable_state
.read()
.expect("subagent state rwlock poisoned");
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
let available_defs: Vec<&SubagentDef> = catalog let available_defs: Vec<&SubagentDef> = catalog
.all() .all()
.into_iter() .into_iter()
@ -1377,7 +1447,13 @@ impl SubagentRuntime {
enabled: bool, enabled: bool,
) -> Result<SubagentAvailabilityChange, String> { ) -> Result<SubagentAvailabilityChange, String> {
// 校验子代理存在 // 校验子代理存在
if self.catalog.read().expect("subagent catalog rwlock poisoned").find(name).is_none() { if self
.catalog
.read()
.expect("subagent catalog rwlock poisoned")
.find(name)
.is_none()
{
return Err(format!("subagent '{}' not found", name)); return Err(format!("subagent '{}' not found", name));
} }
@ -1452,7 +1528,10 @@ impl SubagentRuntime {
reload: bool, reload: bool,
) -> Result<SubagentDef, String> { ) -> Result<SubagentDef, String> {
let def = { let def = {
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
catalog catalog
.find(name) .find(name)
.ok_or_else(|| format!("subagent '{}' not found", name))? .ok_or_else(|| format!("subagent '{}' not found", name))?
@ -1471,7 +1550,9 @@ impl SubagentRuntime {
let next_description = description.unwrap_or(&def.description); let next_description = description.unwrap_or(&def.description);
let next_body = body.unwrap_or(def.body.as_deref().unwrap_or("")); let next_body = body.unwrap_or(def.body.as_deref().unwrap_or(""));
let next_capability = capability.cloned().unwrap_or_else(|| def.capability.clone()); let next_capability = capability
.cloned()
.unwrap_or_else(|| def.capability.clone());
let next_provider = provider.cloned().unwrap_or(def.provider); let next_provider = provider.cloned().unwrap_or(def.provider);
let next_model = model.cloned().unwrap_or(def.model); let next_model = model.cloned().unwrap_or(def.model);
@ -1516,15 +1597,14 @@ impl SystemPromptProvider for SubagentPromptProvider {
// 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认) // 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认)
let content = match context.session_id.as_deref() { let content = match context.session_id.as_deref() {
Some(sid) => { Some(sid) => {
let policy = self let policy = self.experts.selected_expert_for(sid).map(|e| e.capability);
.experts
.selected_expert_for(sid)
.map(|e| e.capability);
match policy { match policy {
Some(p) if p.has_subagent_policy() => self.runtime.system_index_prompt_filtered_with_policy( Some(p) if p.has_subagent_policy() => {
self.runtime.system_index_prompt_filtered_with_policy(
p.allowed_subagents.as_deref(), p.allowed_subagents.as_deref(),
&p.denied_subagents, &p.denied_subagents,
), )
}
_ => self.runtime.system_index_prompt_filtered(), _ => self.runtime.system_index_prompt_filtered(),
} }
} }
@ -1671,8 +1751,7 @@ fn load_subagents_from_root(root: &Path, source: SubagentSource) -> Vec<Subagent
/// 解析子代理文件 /// 解析子代理文件
fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDef, String> { fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDef, String> {
let content = fs::read_to_string(path) let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
.map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter, body) = match crate::frontmatter::parse::<SubagentFrontmatter>(&content) { let (frontmatter, body) = match crate::frontmatter::parse::<SubagentFrontmatter>(&content) {
Ok(v) => v, Ok(v) => v,
@ -1695,7 +1774,11 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
.unwrap_or_else(|| "unknown-subagent".to_string()); .unwrap_or_else(|| "unknown-subagent".to_string());
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string(); let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
let prompt_template = frontmatter.prompt_template.unwrap_or_default().trim().to_string(); let prompt_template = frontmatter
.prompt_template
.unwrap_or_default()
.trim()
.to_string();
let body_content = body.trim().to_string(); let body_content = body.trim().to_string();
let capability = CapabilityPolicy { let capability = CapabilityPolicy {
@ -1720,7 +1803,11 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
name, name,
description: frontmatter.description.trim().to_string(), description: frontmatter.description.trim().to_string(),
prompt_template, prompt_template,
body: if body_content.is_empty() { None } else { Some(body_content) }, body: if body_content.is_empty() {
None
} else {
Some(body_content)
},
capability, capability,
max_execution_secs: frontmatter.max_execution_secs, max_execution_secs: frontmatter.max_execution_secs,
source, source,
@ -1948,9 +2035,7 @@ mod tests {
let items = runtime.list_with_status(); let items = runtime.list_with_status();
let general = items.iter().find(|i| i.name == "general").unwrap(); let general = items.iter().find(|i| i.name == "general").unwrap();
assert!(general assert!(general.disabled_in_scopes.contains(&"project".to_string()));
.disabled_in_scopes
.contains(&"project".to_string()));
} }
#[test] #[test]
@ -2059,10 +2144,7 @@ mod tests {
let base = base_registry(); let base = base_registry();
let p = policy(None, &[]); let p = policy(None, &[]);
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true);
assert_eq!( assert_eq!(sorted_names(&reg), vec!["bash", "edit", "read", "write"]);
sorted_names(&reg),
vec!["bash", "edit", "read", "write"]
);
} }
#[test] #[test]
@ -2158,10 +2240,7 @@ mod tests {
def.capability.allowed_skills.as_deref(), def.capability.allowed_skills.as_deref(),
Some(["skill_a".to_string(), "skill_b".to_string()].as_slice()) Some(["skill_a".to_string(), "skill_b".to_string()].as_slice())
); );
assert_eq!( assert_eq!(def.capability.denied_skills, vec!["skill_c".to_string()]);
def.capability.denied_skills,
vec!["skill_c".to_string()]
);
assert_eq!( assert_eq!(
def.capability.allowed_tools.as_deref(), def.capability.allowed_tools.as_deref(),
Some(["read".to_string(), "todo_write".to_string()].as_slice()) Some(["read".to_string(), "todo_write".to_string()].as_slice())
@ -2273,16 +2352,8 @@ mod tests {
#[test] #[test]
fn render_subagent_file_omits_empty_capability() { fn render_subagent_file_omits_empty_capability() {
let cap = CapabilityPolicy::default(); let cap = CapabilityPolicy::default();
let content = render_subagent_file( let content =
"basic", render_subagent_file("basic", "basic agent", "", "body", &cap, None, &None, &None)
"basic agent",
"",
"body",
&cap,
None,
&None,
&None,
)
.unwrap(); .unwrap();
// 空 capability 字段不应出现在 YAML 中 // 空 capability 字段不应出现在 YAML 中
assert!(!content.contains("allowed_skills")); assert!(!content.contains("allowed_skills"));
@ -2327,10 +2398,21 @@ mod tests {
denied_subagents: vec![], denied_subagents: vec![],
}; };
let updated = runtime let updated = runtime
.update_subagent("demo", Some("updated desc"), None, Some(&new_cap), Some(&None), Some(&None), false) .update_subagent(
"demo",
Some("updated desc"),
None,
Some(&new_cap),
Some(&None),
Some(&None),
false,
)
.unwrap(); .unwrap();
assert_eq!(updated.description, "updated desc"); assert_eq!(updated.description, "updated desc");
assert_eq!(updated.capability.denied_skills, vec!["skill_x".to_string()]); assert_eq!(
updated.capability.denied_skills,
vec!["skill_x".to_string()]
);
assert_eq!( assert_eq!(
updated.capability.allowed_tools.as_deref(), updated.capability.allowed_tools.as_deref(),
Some(["read".to_string()].as_slice()) Some(["read".to_string()].as_slice())
@ -2339,7 +2421,10 @@ mod tests {
// 重新从文件 parse 验证写回成功 // 重新从文件 parse 验证写回成功
let reparsed = parse_subagent_file(&path, SubagentSource::Project).unwrap(); let reparsed = parse_subagent_file(&path, SubagentSource::Project).unwrap();
assert_eq!(reparsed.description, "updated desc"); assert_eq!(reparsed.description, "updated desc");
assert_eq!(reparsed.capability.denied_skills, vec!["skill_x".to_string()]); assert_eq!(
reparsed.capability.denied_skills,
vec!["skill_x".to_string()]
);
} }
#[test] #[test]

View File

@ -107,9 +107,7 @@ impl Tool for TaskTool {
return Ok(ToolResult { return Ok(ToolResult {
success: false, success: false,
output: String::new(), output: String::new(),
error: Some( error: Some("description should be 1-5 words, max 50 characters".to_string()),
"description should be 1-5 words, max 50 characters".to_string(),
),
}); });
} }

View File

@ -2,9 +2,9 @@ use async_trait::async_trait;
use chrono::{DateTime, Days, Duration, Months, Utc}; use chrono::{DateTime, Days, Duration, Months, Utc};
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::tools::extract_u64;
use crate::tools::check_null_args;
use super::traits::{Tool, ToolResult}; use super::traits::{Tool, ToolResult};
use crate::tools::check_null_args;
use crate::tools::extract_u64;
pub struct TimeTool { pub struct TimeTool {
default_timezone: String, default_timezone: String,
@ -468,7 +468,11 @@ mod tests {
json!({"direction": "future", "amount": "7", "unit": "days"}), json!({"direction": "future", "amount": "7", "unit": "days"}),
); );
assert!(result.success, "Expected success but got error: {:?}", result.error); assert!(
result.success,
"Expected success but got error: {:?}",
result.error
);
let payload: Value = serde_json::from_str(&result.output).unwrap(); let payload: Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(payload["result_time"], "2026-05-04T12:30:00+08:00"); assert_eq!(payload["result_time"], "2026-05-04T12:30:00+08:00");
assert_eq!(payload["offset"]["amount"], 7); assert_eq!(payload["offset"]["amount"], 7);
@ -483,7 +487,13 @@ mod tests {
); );
assert!(!result.success); assert!(!result.success);
assert!(result.error.as_deref().unwrap().contains("Missing required parameter: amount")); assert!(
result
.error
.as_deref()
.unwrap()
.contains("Missing required parameter: amount")
);
} }
#[test] #[test]
@ -493,7 +503,13 @@ mod tests {
// Null args should return error (current time requires no params, but null is invalid) // Null args should return error (current time requires no params, but null is invalid)
assert!(!result.success); assert!(!result.success);
assert!(result.error.as_deref().unwrap().contains("Missing required parameters")); assert!(
result
.error
.as_deref()
.unwrap()
.contains("Missing required parameters")
);
} }
#[test] #[test]

Some files were not shown because too many files have changed in this diff Show More