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:
parent
f37a5ffe6e
commit
cda14360af
83
.github/workflows/ci.yml
vendored
Normal file
83
.github/workflows/ci.yml
vendored
Normal 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
|
||||
17
Cargo.toml
17
Cargo.toml
@ -3,6 +3,23 @@ name = "picobot"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
# 编译期硬错误:避免明显的内存安全/正确性隐患
|
||||
unsafe_op_in_unsafe_fn = "warn"
|
||||
rust_2018_idioms = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
# 渐进式策略:
|
||||
# - 不直接声明 lint group(correctness/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]
|
||||
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "multipart", "stream"] }
|
||||
dotenv = "0.15"
|
||||
|
||||
19
Makefile
19
Makefile
@ -1,6 +1,6 @@
|
||||
# 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
|
||||
all: build
|
||||
@ -47,11 +47,22 @@ clean:
|
||||
|
||||
# Check code formatting and linting
|
||||
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
|
||||
@echo "Checking Rust code..."
|
||||
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:
|
||||
@ -66,4 +77,6 @@ help:
|
||||
@echo " make run - Run production build"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
@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"
|
||||
|
||||
12
rustfmt.toml
Normal file
12
rustfmt.toml
Normal file
@ -0,0 +1,12 @@
|
||||
# PicoBot Rust 代码格式化规则
|
||||
#
|
||||
# 设计原则:尽量贴近 rustfmt 默认风格,仅固化少数项目级偏好。
|
||||
# 不追求激进重排,避免一次性产生大量 diff。
|
||||
# 仅使用 stable rustfmt 支持的选项,不依赖 nightly 特性。
|
||||
|
||||
# 行宽:100,现代显示器友好
|
||||
max_width = 100
|
||||
|
||||
# 缩进用 4 空格(Rust 社区主流,与现有代码一致)
|
||||
hard_tabs = false
|
||||
tab_spaces = 4
|
||||
@ -2,12 +2,14 @@ use crate::agent::AgentRuntimeConfig;
|
||||
use crate::agent::{SystemPromptContext, SystemPromptProvider};
|
||||
use crate::bus::ChatMessage;
|
||||
use crate::bus::message::ToolMessageState;
|
||||
use crate::storage::ConversationRepository;
|
||||
use crate::domain::messages::{ContentBlock, ToolCall};
|
||||
use crate::observability::{
|
||||
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::tools::{ToolContext, ToolRegistry};
|
||||
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())
|
||||
.count();
|
||||
|
||||
@ -284,7 +288,9 @@ fn filter_images_by_age_and_count(
|
||||
|
||||
// 过滤图片:保留非图片媒体和指定数量的图片
|
||||
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| {
|
||||
if supported_image_mime_type(path).is_some() {
|
||||
if images_kept_in_msg < keep_count {
|
||||
@ -300,16 +306,22 @@ fn filter_images_by_age_and_count(
|
||||
.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())
|
||||
.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())
|
||||
.count();
|
||||
|
||||
let content = if original_image_count > filtered_image_count {
|
||||
let notice = if exceeds_age_limit {
|
||||
format!("{} [图片已过期:超出 {} 条消息范围]", message.content, max_age_rounds)
|
||||
format!(
|
||||
"{} [图片已过期:超出 {} 条消息范围]",
|
||||
message.content, max_age_rounds
|
||||
)
|
||||
} else {
|
||||
format!("{} [图片已过期:超出最大图片数量限制]", message.content)
|
||||
};
|
||||
@ -705,7 +717,12 @@ impl<H: EmittedMessageHandler> PersistingEmittedMessageHandler<H> {
|
||||
session_id: impl Into<String>,
|
||||
topic_id: Option<String>,
|
||||
) -> Self {
|
||||
Self { inner, conversation_repository, session_id: session_id.into(), topic_id }
|
||||
Self {
|
||||
inner,
|
||||
conversation_repository,
|
||||
session_id: session_id.into(),
|
||||
topic_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -720,17 +737,15 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
|
||||
let topic_id = self.topic_id.clone();
|
||||
let msg_for_persist = message.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = repo.append_message_with_topic(
|
||||
&session_id,
|
||||
topic_id.as_deref(),
|
||||
&msg_for_persist,
|
||||
) {
|
||||
if let Err(e) =
|
||||
repo.append_message_with_topic(&session_id, topic_id.as_deref(), &msg_for_persist)
|
||||
{
|
||||
tracing::error!(error = %e, session_id = %session_id,
|
||||
"Failed to persist emitted message");
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok(); // JoinError 不影响主流程
|
||||
.ok(); // JoinError 不影响主流程
|
||||
self.inner.handle(message).await;
|
||||
}
|
||||
|
||||
@ -741,11 +756,9 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
|
||||
let topic_id = self.topic_id.clone();
|
||||
let msg_for_persist = message.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = repo.append_message_with_topic(
|
||||
&session_id,
|
||||
topic_id.as_deref(),
|
||||
&msg_for_persist,
|
||||
) {
|
||||
if let Err(e) =
|
||||
repo.append_message_with_topic(&session_id, topic_id.as_deref(), &msg_for_persist)
|
||||
{
|
||||
tracing::error!(error = %e, session_id = %session_id,
|
||||
"Failed to persist emitted message");
|
||||
}
|
||||
@ -925,13 +938,15 @@ impl AgentLoop {
|
||||
// Sanitize: remove any trailing incomplete tool call sequences
|
||||
// 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_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|tc| tc.id.clone())
|
||||
.collect();
|
||||
let tool_result_ids: Vec<_> = messages.iter()
|
||||
let tool_result_ids: Vec<_> = messages
|
||||
.iter()
|
||||
.filter(|m| m.role == "tool")
|
||||
.filter_map(|m| m.tool_call_id.clone())
|
||||
.collect();
|
||||
@ -982,7 +997,8 @@ impl AgentLoop {
|
||||
if self.check_cancelled().await {
|
||||
tracing::info!(iteration, "Agent execution cancelled by user");
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
// Pre-generate the message ID so stream deltas and the final assistant
|
||||
@ -1054,7 +1075,10 @@ impl AgentLoop {
|
||||
drop(stream_callback);
|
||||
} else {
|
||||
// 无取消令牌: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
|
||||
@ -1074,7 +1098,8 @@ impl AgentLoop {
|
||||
let assistant_message =
|
||||
ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
||||
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 {
|
||||
final_response: assistant_message,
|
||||
emitted_messages,
|
||||
@ -1104,9 +1129,14 @@ impl AgentLoop {
|
||||
|
||||
// If no tool calls, this is the final response
|
||||
if response.tool_calls.is_empty() {
|
||||
let result = self.build_final_response(
|
||||
response, &streaming_message_id, had_streaming, &mut emitted_messages,
|
||||
).await;
|
||||
let result = self
|
||||
.build_final_response(
|
||||
response,
|
||||
&streaming_message_id,
|
||||
had_streaming,
|
||||
&mut emitted_messages,
|
||||
)
|
||||
.await;
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@ -1177,9 +1207,13 @@ impl AgentLoop {
|
||||
};
|
||||
|
||||
self.process_tool_results(
|
||||
&response.tool_calls, &tool_results, &mut loop_detector,
|
||||
&mut messages, &mut emitted_messages,
|
||||
).await;
|
||||
&response.tool_calls,
|
||||
&tool_results,
|
||||
&mut loop_detector,
|
||||
&mut messages,
|
||||
&mut emitted_messages,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Loop continues to next iteration with updated messages
|
||||
// PendingUserAction 工具的结果已在上方加入 messages,
|
||||
@ -1193,7 +1227,9 @@ impl AgentLoop {
|
||||
}
|
||||
|
||||
// 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,永远不返回。
|
||||
@ -1255,11 +1291,8 @@ impl AgentLoop {
|
||||
&filtered_messages,
|
||||
system_prompt.as_ref().map(|p| p.content.as_str()),
|
||||
);
|
||||
let image_tokens = image_token_budget_for_request(
|
||||
&self.runtime_config,
|
||||
text_tokens,
|
||||
tools_tokens,
|
||||
);
|
||||
let image_tokens =
|
||||
image_token_budget_for_request(&self.runtime_config, text_tokens, tools_tokens);
|
||||
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
|
||||
|
||||
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();
|
||||
}
|
||||
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 {
|
||||
final_response: assistant_message,
|
||||
emitted_messages: std::mem::take(emitted_messages),
|
||||
@ -1360,7 +1394,10 @@ impl AgentLoop {
|
||||
// Defense: sanitize before final summary request
|
||||
let removed = Self::sanitize_messages_for_llm(messages);
|
||||
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
|
||||
@ -1394,13 +1431,15 @@ impl AgentLoop {
|
||||
|
||||
match final_result {
|
||||
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)
|
||||
} else {
|
||||
ChatMessage::assistant(response.content)
|
||||
};
|
||||
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 {
|
||||
final_response: assistant_message,
|
||||
emitted_messages: std::mem::take(emitted_messages),
|
||||
@ -1416,7 +1455,8 @@ impl AgentLoop {
|
||||
);
|
||||
let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
||||
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 {
|
||||
final_response: final_message,
|
||||
emitted_messages: std::mem::take(emitted_messages),
|
||||
@ -1533,9 +1573,7 @@ impl AgentLoop {
|
||||
// Log function call with name and arguments before execution
|
||||
let args_str = match &tool_call.arguments {
|
||||
serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(),
|
||||
other => {
|
||||
serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string())
|
||||
}
|
||||
other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
|
||||
};
|
||||
tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool");
|
||||
|
||||
@ -1571,7 +1609,8 @@ impl AgentLoop {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::warn!(tool = %tool_call.name, "Tool not found");
|
||||
let skill_hint = self.skills
|
||||
let skill_hint = self
|
||||
.skills
|
||||
.as_ref()
|
||||
.and_then(|s| s.matching_skill_summary(&tool_call.name));
|
||||
let error = match skill_hint {
|
||||
@ -1581,10 +1620,7 @@ impl AgentLoop {
|
||||
),
|
||||
None => format!("Tool '{}' not found", tool_call.name),
|
||||
};
|
||||
return ToolExecutionOutcome::failure(
|
||||
format!("Error: {}", error),
|
||||
Some(error),
|
||||
);
|
||||
return ToolExecutionOutcome::failure(format!("Error: {}", error), Some(error));
|
||||
}
|
||||
};
|
||||
|
||||
@ -1977,10 +2013,7 @@ mod tests {
|
||||
// 创建 3 条消息,每条都有图片
|
||||
let messages: Vec<ChatMessage> = (0..3)
|
||||
.map(|i| {
|
||||
ChatMessage::user_with_media(
|
||||
format!("message {}", i),
|
||||
vec![jpg_paths[i].clone()],
|
||||
)
|
||||
ChatMessage::user_with_media(format!("message {}", i), vec![jpg_paths[i].clone()])
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -2148,7 +2181,8 @@ mod tests {
|
||||
// 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 2 removes the orphaned tool result for call_1 (its parent
|
||||
// assistant was removed).
|
||||
@ -2181,9 +2215,7 @@ mod tests {
|
||||
fn test_sanitize_removes_orphaned_tool_messages() {
|
||||
// A lone tool message without a preceding assistant tool_calls
|
||||
// is orphaned and should be removed.
|
||||
let mut messages = vec![
|
||||
ChatMessage::tool("call_1", "calculator", "2"),
|
||||
];
|
||||
let mut messages = vec![ChatMessage::tool("call_1", "calculator", "2")];
|
||||
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
assert_eq!(removed, 1);
|
||||
@ -2398,7 +2430,6 @@ mod tests {
|
||||
ChatMessage::tool("t1_call", "read", "content A"),
|
||||
ChatMessage::assistant("task 1 is done"),
|
||||
// End of task 1 — complete sequence
|
||||
|
||||
ChatMessage::user("task 2"),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
"doing task 2 — this got interrupted",
|
||||
@ -2417,7 +2448,6 @@ mod tests {
|
||||
),
|
||||
// Missing BOTH tool results — process was killed here
|
||||
// End of task 2 — orphaned sequence in the middle
|
||||
|
||||
ChatMessage::user("task 3"),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
"doing task 3",
|
||||
@ -2526,11 +2556,19 @@ mod tests {
|
||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||
// The assistant should be removed (tool_calls stripped via removal)
|
||||
// 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[0].role, "user");
|
||||
assert!(messages.iter().all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
|
||||
"no assistant should have tool_calls remaining");
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
|
||||
"no assistant should have tool_calls remaining"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -2550,7 +2588,10 @@ mod tests {
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::agent::{AgentError, AgentRuntimeConfig};
|
||||
use crate::bus::{
|
||||
ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION,
|
||||
SYSTEM_CONTEXT_SCHEDULED_PROMPT,
|
||||
@ -5,7 +6,6 @@ use crate::bus::{
|
||||
use crate::config::LLMProviderConfig;
|
||||
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider};
|
||||
use crate::text::{char_count, take_prefix_chars};
|
||||
use crate::agent::{AgentError, AgentRuntimeConfig};
|
||||
|
||||
const TOKEN_ESTIMATE_SAFETY_MULTIPLIER: f64 = 1.2;
|
||||
const CJK_CHARS_PER_TOKEN: f64 = 2.0;
|
||||
@ -50,9 +50,9 @@ impl HistoryUnit {
|
||||
/// Estimate tokens for this unit alone.
|
||||
fn estimate_tokens(&self) -> usize {
|
||||
match self {
|
||||
HistoryUnit::SystemGuard(msg) | HistoryUnit::UserMessage(msg) | HistoryUnit::AssistantText(msg) => {
|
||||
estimate_tokens(std::slice::from_ref(msg))
|
||||
}
|
||||
HistoryUnit::SystemGuard(msg)
|
||||
| HistoryUnit::UserMessage(msg)
|
||||
| HistoryUnit::AssistantText(msg) => estimate_tokens(std::slice::from_ref(msg)),
|
||||
HistoryUnit::ToolRound { assistant, results } => {
|
||||
let mut all = vec![assistant.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
|
||||
let content_tokens = (cjk_count as f64 / CJK_CHARS_PER_TOKEN)
|
||||
+ (other_count as f64 / OTHER_CHARS_PER_TOKEN);
|
||||
let content_tokens =
|
||||
(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.)
|
||||
let json_overhead = messages.len() * JSON_OVERHEAD_PER_MESSAGE;
|
||||
@ -1114,9 +1114,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_estimate_tokens_mixed_content() {
|
||||
let messages = vec![
|
||||
ChatMessage::user("Hello 世界 this is 测试"),
|
||||
];
|
||||
let messages = vec![ChatMessage::user("Hello 世界 this is 测试")];
|
||||
|
||||
let tokens = estimate_tokens(&messages);
|
||||
// Content: 18 English chars + 4 CJK chars
|
||||
@ -1408,8 +1406,12 @@ mod tests {
|
||||
// All units are AssistantText, split at 50% token ratio
|
||||
let split = compressor.find_safe_split_point(&units, 0.5);
|
||||
// Should split somewhere in the middle (not 0, not len())
|
||||
assert!(split > 0 && split < units.len(),
|
||||
"split {} should be between 0 and {}", split, units.len());
|
||||
assert!(
|
||||
split > 0 && split < units.len(),
|
||||
"split {} should be between 0 and {}",
|
||||
split,
|
||||
units.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1458,10 +1460,14 @@ mod tests {
|
||||
assert_eq!(compressed.len(), 3);
|
||||
// Critical invariant: NO tool_calls or tool_call_id anywhere
|
||||
for msg in &compressed {
|
||||
assert!(msg.tool_calls.is_none(),
|
||||
"compress_two_segment output should never contain tool_calls");
|
||||
assert!(msg.tool_call_id.is_none(),
|
||||
"compress_two_segment output should never contain tool_call_id");
|
||||
assert!(
|
||||
msg.tool_calls.is_none(),
|
||||
"compress_two_segment output should never contain tool_calls"
|
||||
);
|
||||
assert!(
|
||||
msg.tool_call_id.is_none(),
|
||||
"compress_two_segment output should never contain tool_call_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,6 @@ pub use agent_loop::{
|
||||
pub use context_compressor::ContextCompressor;
|
||||
pub use runtime_config::AgentRuntimeConfig;
|
||||
pub use system_prompt::{
|
||||
CompositeSystemPromptProvider, generate_system_env_prompt, SystemPrompt, SystemPromptContext,
|
||||
SystemPromptProvider,
|
||||
CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider,
|
||||
generate_system_env_prompt,
|
||||
};
|
||||
|
||||
@ -23,4 +23,4 @@ pub fn initialize_process_runtime() {
|
||||
// optionally the RUST_BACKTRACE-based backtrace.
|
||||
default_hook(info);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ pub struct MediaItem {
|
||||
pub mime_type: Option<String>,
|
||||
pub original_key: Option<String>, // Feishu file_key for download
|
||||
pub content_base64: Option<String>, // Base64-encoded file content for web download
|
||||
pub file_name: Option<String>, // Display file name
|
||||
pub file_name: Option<String>, // Display file name
|
||||
}
|
||||
|
||||
impl MediaItem {
|
||||
@ -284,12 +284,13 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
||||
}
|
||||
|
||||
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 all_have_results = tool_calls
|
||||
.iter()
|
||||
.all(|tc| resolved_ids.contains(&tc.id));
|
||||
let all_have_results = tool_calls.iter().all(|tc| resolved_ids.contains(&tc.id));
|
||||
|
||||
if all_have_results {
|
||||
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"
|
||||
&& 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);
|
||||
if !already_marked {
|
||||
pending_tool_ids = m.tool_calls.as_ref().unwrap()
|
||||
.iter().map(|tc| tc.id.clone()).collect();
|
||||
pending_tool_ids = m
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|tc| tc.id.clone())
|
||||
.collect();
|
||||
pending_assistant_idx = Some(i);
|
||||
}
|
||||
} else if m.role == "tool" {
|
||||
@ -511,7 +519,10 @@ pub enum OutboundEventKind {
|
||||
|
||||
impl OutboundMessage {
|
||||
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(
|
||||
@ -548,7 +559,8 @@ impl OutboundMessage {
|
||||
reply_to: Option<String>,
|
||||
metadata: HashMap<String, String>,
|
||||
) -> 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
|
||||
}
|
||||
@ -561,7 +573,8 @@ impl OutboundMessage {
|
||||
reply_to: Option<String>,
|
||||
metadata: HashMap<String, String>,
|
||||
) -> 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
|
||||
}
|
||||
@ -595,7 +608,7 @@ impl OutboundMessage {
|
||||
message_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn tool_result(
|
||||
channel: impl Into<String>,
|
||||
chat_id: impl Into<String>,
|
||||
@ -626,7 +639,7 @@ impl OutboundMessage {
|
||||
message_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn tool_pending(
|
||||
channel: impl Into<String>,
|
||||
chat_id: impl Into<String>,
|
||||
@ -657,7 +670,7 @@ impl OutboundMessage {
|
||||
message_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 构造流式文本增量消息
|
||||
pub fn stream_delta(
|
||||
channel: impl Into<String>,
|
||||
@ -685,7 +698,7 @@ impl OutboundMessage {
|
||||
message_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 构造流式结束信号
|
||||
pub fn stream_end(
|
||||
channel: impl Into<String>,
|
||||
@ -749,7 +762,8 @@ impl OutboundMessage {
|
||||
"assistant" => {
|
||||
if let Some(tool_calls) = &message.tool_calls {
|
||||
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 {
|
||||
let mut resp = Self::assistant(
|
||||
channel.to_string(),
|
||||
@ -766,7 +780,11 @@ impl OutboundMessage {
|
||||
|
||||
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复;
|
||||
// 只有 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| {
|
||||
let mut tc = Self::tool_call(
|
||||
channel.to_string(),
|
||||
@ -930,10 +948,7 @@ mod tests {
|
||||
"calculator\nargs: {\"expression\":\"1 + 1\"}"
|
||||
);
|
||||
assert_eq!(outbound[1].tool_name.as_deref(), Some("read"));
|
||||
assert_eq!(
|
||||
outbound[1].content,
|
||||
"read\nargs: {\"path\":\"README.md\"}"
|
||||
);
|
||||
assert_eq!(outbound[1].content, "read\nargs: {\"path\":\"README.md\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -84,7 +84,10 @@ impl Channel for CliChannel {
|
||||
self.shutdown_token.cancel();
|
||||
let count = self.connections.read().await.len();
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@ -10,8 +10,8 @@ use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::{RwLock, broadcast};
|
||||
|
||||
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
|
||||
use crate::bus::message::OutboundEventKind;
|
||||
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
|
||||
use crate::channels::base::{Channel, ChannelError};
|
||||
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
|
||||
use crate::text::{char_count, truncate_with_ellipsis};
|
||||
@ -548,9 +548,10 @@ impl FeishuChannel {
|
||||
}
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
ChannelError::Other(format!("Read upload image response error: {}", e))
|
||||
})?;
|
||||
let body = resp
|
||||
.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| {
|
||||
ChannelError::Other(format!(
|
||||
"Parse upload image response error: {} (status={}, body={})",
|
||||
@ -631,9 +632,10 @@ impl FeishuChannel {
|
||||
}
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
ChannelError::Other(format!("Read upload file response error: {}", e))
|
||||
})?;
|
||||
let body = resp
|
||||
.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| {
|
||||
ChannelError::Other(format!(
|
||||
"Parse upload file response error: {} (status={}, body={})",
|
||||
@ -982,9 +984,11 @@ impl FeishuChannel {
|
||||
reply_to: Option<&str>,
|
||||
) -> Result<(), ChannelError> {
|
||||
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 {
|
||||
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,16 +1444,20 @@ fn parse_post_content(content: &str) -> String {
|
||||
}
|
||||
"code_block" => {
|
||||
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()) {
|
||||
content_arr
|
||||
.iter()
|
||||
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
} else {
|
||||
// Fallback to text field for backwards compatibility
|
||||
el.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string()
|
||||
};
|
||||
let code_text =
|
||||
if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) {
|
||||
content_arr
|
||||
.iter()
|
||||
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
} else {
|
||||
// Fallback to text field for backwards compatibility
|
||||
el.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
};
|
||||
out.push(format!("\n```{}\n{}\n```\n", lang, code_text));
|
||||
}
|
||||
_ => {
|
||||
@ -2380,7 +2388,8 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_post_content_handles_empty_code_block() {
|
||||
// Test code_block with empty content
|
||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
|
||||
let post_json =
|
||||
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
|
||||
let result = parse_post_content(post_json);
|
||||
assert!(result.contains("```go"));
|
||||
}
|
||||
@ -2461,8 +2470,18 @@ impl Channel for FeishuChannel {
|
||||
}
|
||||
|
||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
||||
if matches!(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)
|
||||
if matches!(
|
||||
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(());
|
||||
}
|
||||
@ -2553,8 +2572,14 @@ impl Channel for FeishuChannel {
|
||||
}
|
||||
|
||||
if !msg.content.trim().is_empty() {
|
||||
self.dispatch_send(receive_id, receive_id_type, "text", msg.content.trim(), reply_to)
|
||||
.await?;
|
||||
self.dispatch_send(
|
||||
receive_id,
|
||||
receive_id_type,
|
||||
"text",
|
||||
msg.content.trim(),
|
||||
reply_to,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut sent_media = 0usize;
|
||||
|
||||
@ -48,7 +48,9 @@ impl ChannelManager {
|
||||
) -> Result<(), ChannelError> {
|
||||
for (name, channel_config) in &config.channels {
|
||||
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) => {
|
||||
if feishu_config.enabled {
|
||||
let channel = FeishuChannel::new(
|
||||
@ -72,7 +74,9 @@ impl ChannelManager {
|
||||
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 {
|
||||
let channel = WechatChannel::new(
|
||||
name.clone(),
|
||||
@ -253,8 +257,14 @@ mod tests {
|
||||
names.sort();
|
||||
|
||||
assert_eq!(names, vec!["backup", "primary", "websocket"]);
|
||||
assert_eq!(manager.get_channel("primary").await.unwrap().name(), "primary");
|
||||
assert_eq!(manager.get_channel("backup").await.unwrap().name(), "backup");
|
||||
assert_eq!(
|
||||
manager.get_channel("primary").await.unwrap().name(),
|
||||
"primary"
|
||||
);
|
||||
assert_eq!(
|
||||
manager.get_channel("backup").await.unwrap().name(),
|
||||
"backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -295,7 +305,8 @@ mod tests {
|
||||
"cred_path": "<CRED_PATH>"
|
||||
}
|
||||
}
|
||||
}"#.replace("<CRED_PATH>", &cred_path_json),
|
||||
}"#
|
||||
.replace("<CRED_PATH>", &cred_path_json),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@ -314,6 +325,9 @@ mod tests {
|
||||
names.sort();
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ use tokio::sync::RwLock;
|
||||
use tokio::task::JoinHandle;
|
||||
use wechatbot::{BotOptions, SendContent, WeChatBot};
|
||||
|
||||
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
|
||||
use crate::bus::message::OutboundEventKind;
|
||||
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
|
||||
use crate::channels::base::{Channel, ChannelError};
|
||||
use crate::config::{LLMProviderConfig, WechatChannelConfig};
|
||||
|
||||
@ -55,7 +55,10 @@ impl WechatChannel {
|
||||
}
|
||||
|
||||
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(
|
||||
@ -132,14 +135,17 @@ impl WechatChannel {
|
||||
) -> Result<Vec<MediaItem>, ChannelError> {
|
||||
let Some(downloaded) = bot.download(&msg).await.map_err(|error| {
|
||||
ChannelError::Other(format!("WeChat media download failed: {}", error))
|
||||
})? else {
|
||||
})?
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let media_dir = Self::default_media_dir();
|
||||
tokio::fs::create_dir_all(&media_dir)
|
||||
.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(
|
||||
&downloaded.media_type,
|
||||
@ -149,7 +155,9 @@ impl WechatChannel {
|
||||
let file_path = media_dir.join(&filename);
|
||||
tokio::fs::write(&file_path, downloaded.data)
|
||||
.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");
|
||||
|
||||
@ -316,7 +324,11 @@ impl Channel for WechatChannel {
|
||||
| OutboundEventKind::StreamDelta
|
||||
| OutboundEventKind::StreamEnd
|
||||
| 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(());
|
||||
}
|
||||
@ -344,9 +356,12 @@ impl Channel for WechatChannel {
|
||||
None
|
||||
};
|
||||
let content = Self::media_to_send_content(media, caption)?;
|
||||
self.bot.send_media(&msg.chat_id, content).await.map_err(|error| {
|
||||
ChannelError::SendError(format!("WeChat media send failed: {}", error))
|
||||
})?;
|
||||
self.bot
|
||||
.send_media(&msg.chat_id, content)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ChannelError::SendError(format!("WeChat media send failed: {}", error))
|
||||
})?;
|
||||
tracing::info!(
|
||||
channel = %self.name,
|
||||
chat_id = %msg.chat_id,
|
||||
@ -409,13 +424,12 @@ mod tests {
|
||||
std::fs::rename(file.path(), &doc_path).unwrap();
|
||||
|
||||
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
|
||||
let content = WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap();
|
||||
let content =
|
||||
WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap();
|
||||
|
||||
match content {
|
||||
SendContent::File {
|
||||
file_name,
|
||||
caption,
|
||||
..
|
||||
file_name, caption, ..
|
||||
} => {
|
||||
assert_eq!(file_name, doc_path.file_name().unwrap().to_string_lossy());
|
||||
assert_eq!(caption.as_deref(), Some("note"));
|
||||
@ -423,4 +437,4 @@ mod tests {
|
||||
_ => panic!("expected file send content"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,7 +117,11 @@ impl InitWizard {
|
||||
}
|
||||
|
||||
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> {
|
||||
@ -154,9 +158,9 @@ impl InitWizard {
|
||||
let default_str = (default + 1).to_string();
|
||||
let input = self.prompt_with_default(label, &default_str).await?;
|
||||
|
||||
let selected: usize = input.parse().map_err(|_| {
|
||||
InitError::InputError(format!("Invalid selection: {}", input))
|
||||
})?;
|
||||
let selected: usize = input
|
||||
.parse()
|
||||
.map_err(|_| InitError::InputError(format!("Invalid selection: {}", input)))?;
|
||||
|
||||
if selected == 0 || selected > options.len() {
|
||||
return Err(InitError::InputError(format!(
|
||||
@ -195,9 +199,7 @@ impl InitWizard {
|
||||
println!(" 4. Skip");
|
||||
println!();
|
||||
|
||||
let choice = self
|
||||
.prompt_with_default("Select option", "1")
|
||||
.await?;
|
||||
let choice = self.prompt_with_default("Select option", "1").await?;
|
||||
|
||||
match choice.as_str() {
|
||||
"1" => return self.add_provider(existing).await,
|
||||
@ -243,9 +245,7 @@ impl InitWizard {
|
||||
&mut self,
|
||||
existing: &Config,
|
||||
) -> Result<HashMap<String, ProviderConfig>, InitError> {
|
||||
let provider_name = self
|
||||
.prompt_with_default("Provider name", "default")
|
||||
.await?;
|
||||
let provider_name = self.prompt_with_default("Provider name", "default").await?;
|
||||
|
||||
println!("Provider type:");
|
||||
println!(" 1. openai");
|
||||
@ -307,7 +307,9 @@ impl InitWizard {
|
||||
};
|
||||
let type_options = vec!["openai".to_string(), "anthropic".to_string()];
|
||||
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 base_url = self
|
||||
@ -536,9 +538,7 @@ impl InitWizard {
|
||||
providers: &HashMap<String, ProviderConfig>,
|
||||
models: &HashMap<String, ModelConfig>,
|
||||
) -> Result<HashMap<String, AgentConfig>, InitError> {
|
||||
let agent_name = self
|
||||
.prompt_with_default("Agent name", "default")
|
||||
.await?;
|
||||
let agent_name = self.prompt_with_default("Agent name", "default").await?;
|
||||
|
||||
// Select provider
|
||||
let provider_names: Vec<String> = providers.keys().cloned().collect();
|
||||
@ -600,7 +600,9 @@ impl InitWizard {
|
||||
.position(|p| p == ¤t_agent.provider)
|
||||
.unwrap_or(0);
|
||||
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];
|
||||
|
||||
// Select new model
|
||||
@ -611,7 +613,9 @@ impl InitWizard {
|
||||
.unwrap_or(0);
|
||||
println!();
|
||||
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 agent = AgentConfig {
|
||||
@ -650,7 +654,11 @@ impl InitWizard {
|
||||
if !existing.channels.is_empty() {
|
||||
println!("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!();
|
||||
@ -700,9 +708,7 @@ impl InitWizard {
|
||||
println!("Configuring Feishu channel...");
|
||||
println!();
|
||||
|
||||
let channel_name = self
|
||||
.prompt_with_default("Channel name", "feishu")
|
||||
.await?;
|
||||
let channel_name = self.prompt_with_default("Channel name", "feishu").await?;
|
||||
|
||||
let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu());
|
||||
|
||||
@ -766,7 +772,8 @@ impl InitWizard {
|
||||
println!();
|
||||
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!("WeChat login successful! Credentials saved.");
|
||||
@ -796,9 +803,10 @@ impl InitWizard {
|
||||
})),
|
||||
});
|
||||
|
||||
let creds = bot.login(true).await.map_err(|e| {
|
||||
InitError::WeChatError(format!("WeChat login failed: {}", e))
|
||||
})?;
|
||||
let creds = bot
|
||||
.login(true)
|
||||
.await
|
||||
.map_err(|e| InitError::WeChatError(format!("WeChat login failed: {}", e)))?;
|
||||
|
||||
println!();
|
||||
println!(
|
||||
@ -925,4 +933,4 @@ impl From<std::io::Error> for InitError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
InitError::IoError(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
pub mod channel;
|
||||
pub mod input;
|
||||
pub mod init;
|
||||
pub mod input;
|
||||
|
||||
pub use channel::CliChannel;
|
||||
pub use input::{InputCommand, InputEvent, InputHandler};
|
||||
pub use init::InitWizard;
|
||||
pub use input::{InputCommand, InputEvent, InputHandler};
|
||||
|
||||
@ -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;
|
||||
// Track message IDs that were already streamed so we can skip
|
||||
// the duplicate AssistantResponse that arrives afterwards.
|
||||
let mut streamed_message_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
input.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n").await?;
|
||||
let mut streamed_message_ids: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
input
|
||||
.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n")
|
||||
.await?;
|
||||
|
||||
// Main loop: poll both stdin and WebSocket
|
||||
loop {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::AdapterContext;
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
|
||||
/// 输入适配器:将渠道特定输入转换为 Command
|
||||
///
|
||||
@ -13,11 +13,7 @@ pub trait InputAdapter: Send + Sync {
|
||||
/// - `Ok(Some(Command))`:成功解析为命令
|
||||
/// - `Ok(None)`:不是命令(如普通聊天消息)
|
||||
/// - `Err(CommandError)`:解析错误(如缺少参数)
|
||||
fn try_parse(
|
||||
&self,
|
||||
input: &str,
|
||||
ctx: AdapterContext,
|
||||
) -> Result<Option<Command>, AdapterError>;
|
||||
fn try_parse(&self, input: &str, ctx: AdapterContext) -> Result<Option<Command>, AdapterError>;
|
||||
}
|
||||
|
||||
/// 输出适配器:将 CommandResponse 转换为渠道特定输出
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::adapter::{AdapterError, InputAdapter};
|
||||
use crate::command::context::AdapterContext;
|
||||
use crate::command::Command;
|
||||
|
||||
/// Channel 输入适配器
|
||||
///
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
|
||||
use crate::command::context::AdapterContext;
|
||||
use crate::command::response::{CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
|
||||
/// CLI 输入适配器
|
||||
///
|
||||
@ -313,7 +313,14 @@ mod tests {
|
||||
|
||||
assert!(result.is_some());
|
||||
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]
|
||||
@ -321,7 +328,9 @@ mod tests {
|
||||
let adapter = CliInputAdapter::new();
|
||||
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());
|
||||
let cmd = result.unwrap();
|
||||
@ -344,7 +353,14 @@ mod tests {
|
||||
|
||||
assert!(result.is_some());
|
||||
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]
|
||||
@ -352,7 +368,9 @@ mod tests {
|
||||
let adapter = CliInputAdapter::new();
|
||||
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());
|
||||
let cmd = result.unwrap();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
|
||||
use crate::command::context::AdapterContext;
|
||||
use crate::command::response::{CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::protocol::WsOutbound;
|
||||
|
||||
/// WebSocket 输入适配器
|
||||
@ -79,8 +79,12 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
attachments: Vec::new(),
|
||||
subagent_task_id: None,
|
||||
topic_id: None,
|
||||
timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None,
|
||||
user_message_id: None,
|
||||
},
|
||||
MessageKind::Notification => {
|
||||
// 根据元数据判断具体类型
|
||||
@ -90,9 +94,13 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
response.metadata.get("topic_id"),
|
||||
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) => {
|
||||
let session_id = response.metadata.get("session_id")
|
||||
let session_id = response
|
||||
.metadata
|
||||
.get("session_id")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
WsOutbound::TopicRenamed {
|
||||
@ -106,28 +114,37 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
attachments: Vec::new(),
|
||||
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") {
|
||||
// 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) => {
|
||||
let session_id = response.metadata.get("session_id")
|
||||
let session_id = response
|
||||
.metadata
|
||||
.get("session_id")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
WsOutbound::TopicList {
|
||||
topics,
|
||||
session_id,
|
||||
}
|
||||
WsOutbound::TopicList { topics, session_id }
|
||||
}
|
||||
Err(_) => WsOutbound::AssistantResponse {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
attachments: Vec::new(),
|
||||
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") {
|
||||
@ -139,7 +156,9 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
}
|
||||
} else {
|
||||
// 加载会话
|
||||
let message_count = response.metadata.get("message_count")
|
||||
let message_count = response
|
||||
.metadata
|
||||
.get("message_count")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
WsOutbound::SessionLoaded {
|
||||
@ -150,7 +169,9 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
}
|
||||
} else if let Some(topic_id) = response.metadata.get("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())
|
||||
.unwrap_or(0);
|
||||
WsOutbound::SessionLoaded {
|
||||
@ -166,13 +187,19 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
attachments: Vec::new(),
|
||||
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") {
|
||||
// 会话列表响应
|
||||
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(sessions_json) {
|
||||
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(
|
||||
sessions_json,
|
||||
) {
|
||||
Ok(sessions) => {
|
||||
let channel_name = response.metadata.get("channel_name").cloned();
|
||||
WsOutbound::SessionList {
|
||||
@ -185,28 +212,37 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
attachments: Vec::new(),
|
||||
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") {
|
||||
// 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) => {
|
||||
let session_id = response.metadata.get("session_id")
|
||||
let session_id = response
|
||||
.metadata
|
||||
.get("session_id")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
WsOutbound::TopicList {
|
||||
topics,
|
||||
session_id,
|
||||
}
|
||||
WsOutbound::TopicList { topics, session_id }
|
||||
}
|
||||
Err(_) => WsOutbound::AssistantResponse {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
attachments: Vec::new(),
|
||||
subagent_task_id: None,
|
||||
topic_id: None,
|
||||
timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None,
|
||||
user_message_id: None,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
@ -215,8 +251,12 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
||||
id: response.request_id.to_string(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
attachments: Vec::new(),
|
||||
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(),
|
||||
content: msg.content.clone(),
|
||||
role: "assistant".to_string(),
|
||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None, user_message_id: None,
|
||||
attachments: Vec::new(),
|
||||
subagent_task_id: None,
|
||||
topic_id: None,
|
||||
timestamp: Some(crate::protocol::now_timestamp()),
|
||||
reasoning_content: None,
|
||||
user_message_id: None,
|
||||
},
|
||||
};
|
||||
outbounds.push(outbound);
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use crate::agent::AgentError;
|
||||
use crate::bus::InboundMessage;
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
use crate::agent::AgentError;
|
||||
use crate::gateway::session::SessionManager;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::handlers::list_topics::TopicSummary;
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::gateway::session::SessionManager;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
@ -100,9 +100,8 @@ async fn handle_delete_topic(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let topics_json =
|
||||
serde_json::to_string(&topic_summaries)
|
||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||
let topics_json = serde_json::to_string(&topic_summaries)
|
||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||
|
||||
let message = format!("✓ 已删除话题: {}", topic_title);
|
||||
|
||||
@ -130,9 +129,7 @@ mod tests {
|
||||
|
||||
// 先创建 session 和 topic
|
||||
let session = store.create_session("test_channel", Some("test")).unwrap();
|
||||
let topic = store
|
||||
.create_topic(&session.id, "test topic", None)
|
||||
.unwrap();
|
||||
let topic = store.create_topic(&session.id, "test topic", None).unwrap();
|
||||
|
||||
let ctx = CommandContext::new("test", "test_channel")
|
||||
.with_session_id(&session.id)
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
use crate::agent::context_compressor::estimate_tokens;
|
||||
use crate::agent::{SystemPromptContext, SystemPromptProvider};
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
@ -58,17 +58,23 @@ async fn handle_get_current_session(
|
||||
handler: &GetCurrentSessionCommandHandler,
|
||||
ctx: CommandContext,
|
||||
) -> 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"))?;
|
||||
|
||||
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()))?;
|
||||
|
||||
let topic = handler
|
||||
.store
|
||||
.get_topic(topic_id)
|
||||
.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 内存,重启后也能正确获取历史)
|
||||
let messages = handler
|
||||
@ -88,7 +94,8 @@ async fn handle_get_current_session(
|
||||
user_message_count,
|
||||
};
|
||||
|
||||
provider.build(&system_prompt_context)
|
||||
provider
|
||||
.build(&system_prompt_context)
|
||||
.map(|sp| {
|
||||
use crate::bus::ChatMessage;
|
||||
let system_msg = ChatMessage::system(&sp.content);
|
||||
@ -155,4 +162,4 @@ fn format_time_ago(timestamp_ms: i64) -> String {
|
||||
} else {
|
||||
format!("{} days ago", diff_secs / 86400)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@ -44,8 +44,7 @@ impl CommandHandler for HelpCommandHandler {
|
||||
let metadata = self.metadata.lock().unwrap();
|
||||
let help_text = format_help(&metadata);
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Text, &help_text))
|
||||
Ok(CommandResponse::success(ctx.request_id).with_message(MessageKind::Text, &help_text))
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,4 +57,4 @@ fn format_help(commands: &[CommandMetadata]) -> String {
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use crate::channels::manager::ChannelManager;
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -68,7 +68,6 @@ impl CommandHandler for ListMemoriesCommandHandler {
|
||||
let memories_json = serde_json::to_string(&summaries)
|
||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_metadata("memories", &memories_json))
|
||||
Ok(CommandResponse::success(ctx.request_id).with_metadata("memories", &memories_json))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
use crate::protocol::{SchedulerJobSessionLookup, SchedulerJobSummary};
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
@ -67,8 +67,7 @@ impl CommandHandler for ListSchedulerJobsCommandHandler {
|
||||
let jobs_json = serde_json::to_string(&summaries)
|
||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_metadata("scheduler_jobs", &jobs_json))
|
||||
Ok(CommandResponse::success(ctx.request_id).with_metadata("scheduler_jobs", &jobs_json))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
@ -50,7 +50,9 @@ async fn handle_list_sessions(
|
||||
_include_archived: bool,
|
||||
ctx: CommandContext,
|
||||
) -> 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"))?;
|
||||
|
||||
let topics = handler
|
||||
@ -72,7 +74,8 @@ async fn handle_list_sessions(
|
||||
let marker = if is_current { " *" } else { "" };
|
||||
|
||||
// 使用辅助方法获取消息数量
|
||||
let msg_count = handler.store
|
||||
let msg_count = handler
|
||||
.store
|
||||
.get_topic_message_count(&topic.id)
|
||||
.unwrap_or(0);
|
||||
|
||||
@ -104,4 +107,4 @@ async fn handle_list_sessions(
|
||||
.with_metadata("topics", &topics_json)
|
||||
.with_metadata("count", &topics.len().to_string())
|
||||
.with_metadata("current_topic_id", current_topic_id))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::protocol::SessionSummary;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
use crate::skills::SkillRuntime;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -58,7 +58,6 @@ impl CommandHandler for ListSkillsCommandHandler {
|
||||
let skills_json = serde_json::to_string(&summaries)
|
||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_metadata("skills", &skills_json))
|
||||
Ok(CommandResponse::success(ctx.request_id).with_metadata("skills", &skills_json))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
use crate::protocol::TodoItemSummary;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -50,9 +50,7 @@ impl CommandHandler for ListTopicsCommandHandler {
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
match cmd {
|
||||
Command::ListTopics { session_id } => {
|
||||
handle_list_topics(self, session_id, ctx).await
|
||||
}
|
||||
Command::ListTopics { session_id } => handle_list_topics(self, session_id, ctx).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// 加载指定 channel + chat_id 的对话消息。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
use crate::storage::SessionStore;
|
||||
use crate::tools::task::repository::TaskRepository;
|
||||
use crate::tools::task::types::{TaskSession, TaskSessionState};
|
||||
@ -14,11 +14,11 @@ pub struct LoadTaskMessagesCommandHandler {
|
||||
}
|
||||
|
||||
impl LoadTaskMessagesCommandHandler {
|
||||
pub fn new(
|
||||
task_repository: Arc<dyn TaskRepository>,
|
||||
store: Arc<SessionStore>,
|
||||
) -> Self {
|
||||
Self { task_repository, store }
|
||||
pub fn new(task_repository: Arc<dyn TaskRepository>, store: Arc<SessionStore>) -> Self {
|
||||
Self {
|
||||
task_repository,
|
||||
store,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,11 +62,7 @@ async fn handle_load_task_messages(
|
||||
);
|
||||
|
||||
// 1. Try in-memory repository first
|
||||
let task = match handler
|
||||
.task_repository
|
||||
.load_task_session(&task_id)
|
||||
.await
|
||||
{
|
||||
let task = match handler.task_repository.load_task_session(&task_id).await {
|
||||
Ok(Some(task)) => {
|
||||
tracing::info!(
|
||||
task_id = %task.id,
|
||||
@ -186,6 +182,9 @@ fn parse_subagent_title(title: &str) -> (String, String) {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
@ -37,9 +37,7 @@ impl CommandHandler for LoadTopicCommandHandler {
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
match cmd {
|
||||
Command::LoadTopic { topic_id } => {
|
||||
handle_load_topic(self, topic_id, ctx).await
|
||||
}
|
||||
Command::LoadTopic { topic_id } => handle_load_topic(self, topic_id, ctx).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@ -54,7 +52,9 @@ async fn handle_load_topic(
|
||||
.store
|
||||
.get_topic(&topic_id)
|
||||
.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)
|
||||
.with_message(MessageKind::Notification, &topic.title)
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse};
|
||||
use crate::command::Command;
|
||||
use crate::storage::{MemoryUpsert, SessionStore, GLOBAL_SCOPE_KEY};
|
||||
use crate::storage::{GLOBAL_SCOPE_KEY, MemoryUpsert, SessionStore};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -17,10 +17,7 @@ impl MemoryCrudCommandHandler {
|
||||
}
|
||||
|
||||
/// 通过 ID 查找记忆的 namespace 和 memory_key
|
||||
fn find_by_id(
|
||||
store: &SessionStore,
|
||||
id: &str,
|
||||
) -> Result<Option<(String, String)>, CommandError> {
|
||||
fn find_by_id(store: &SessionStore, id: &str) -> Result<Option<(String, String)>, CommandError> {
|
||||
let records = store
|
||||
.list_memories_for_scope("user", GLOBAL_SCOPE_KEY)
|
||||
.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 {
|
||||
matches!(
|
||||
cmd,
|
||||
Command::CreateMemory { .. } | Command::UpdateMemory { .. } | Command::DeleteMemory { .. }
|
||||
Command::CreateMemory { .. }
|
||||
| Command::UpdateMemory { .. }
|
||||
| Command::DeleteMemory { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@ -112,7 +111,6 @@ impl CommandHandler for MemoryCrudCommandHandler {
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_metadata("memory_updated", "true"))
|
||||
Ok(CommandResponse::success(ctx.request_id).with_metadata("memory_updated", "true"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,15 +4,15 @@ pub mod help;
|
||||
pub mod list_channels;
|
||||
pub mod list_memories;
|
||||
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_by_channel;
|
||||
pub mod list_skills;
|
||||
pub mod list_todos;
|
||||
pub mod list_topics;
|
||||
pub mod load_chat_messages;
|
||||
pub mod load_task_messages;
|
||||
pub mod load_topic;
|
||||
pub mod memory_crud;
|
||||
pub mod rename_topic;
|
||||
pub mod save_session;
|
||||
pub mod save_topic;
|
||||
@ -22,8 +22,7 @@ pub mod switch_topic;
|
||||
|
||||
// 导出公共函数供其他模块复用
|
||||
pub use save_session::{
|
||||
escape_yaml_string, format_message_content, format_timestamp,
|
||||
generate_messages_markdown, generate_system_prompt_markdown,
|
||||
generate_subagent_tasks_markdown, load_subagent_data, SubagentTaskData,
|
||||
SubagentTaskData, escape_yaml_string, format_message_content, format_timestamp,
|
||||
generate_messages_markdown, generate_subagent_tasks_markdown, generate_system_prompt_markdown,
|
||||
load_subagent_data,
|
||||
};
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::handlers::list_topics::TopicSummary;
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
@ -86,7 +86,10 @@ async fn handle_rename_topic(
|
||||
let topic_summaries = serialize_summaries(&topics);
|
||||
|
||||
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("topic_id", &topic_id)
|
||||
.with_metadata("title", trimmed_title)
|
||||
@ -149,9 +152,7 @@ mod tests {
|
||||
let store = handler.store.clone();
|
||||
|
||||
let session = store.create_session("test_channel", Some("test")).unwrap();
|
||||
let topic = store
|
||||
.create_topic(&session.id, "old title", None)
|
||||
.unwrap();
|
||||
let topic = store.create_topic(&session.id, "old title", None).unwrap();
|
||||
|
||||
let ctx = CommandContext::new("test", "test_channel")
|
||||
.with_session_id(&session.id)
|
||||
@ -166,8 +167,14 @@ mod tests {
|
||||
|
||||
let resp = result.unwrap();
|
||||
assert!(resp.success);
|
||||
assert_eq!(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_eq!(
|
||||
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"));
|
||||
|
||||
// 验证存储层已更新
|
||||
@ -181,9 +188,7 @@ mod tests {
|
||||
let store = handler.store.clone();
|
||||
|
||||
let session = store.create_session("test_channel", Some("test")).unwrap();
|
||||
let topic = store
|
||||
.create_topic(&session.id, "old title", None)
|
||||
.unwrap();
|
||||
let topic = store.create_topic(&session.id, "old title", None).unwrap();
|
||||
|
||||
let ctx = CommandContext::new("test", "test_channel")
|
||||
.with_session_id(&session.id)
|
||||
@ -229,9 +234,7 @@ mod tests {
|
||||
let store = handler.store.clone();
|
||||
|
||||
let session = store.create_session("test_channel", Some("test")).unwrap();
|
||||
let topic = store
|
||||
.create_topic(&session.id, "same title", None)
|
||||
.unwrap();
|
||||
let topic = store.create_topic(&session.id, "same title", None).unwrap();
|
||||
let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at;
|
||||
|
||||
// 等待一秒确保 updated_at 会变化(如果真的写入)
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
use crate::agent::AgentError;
|
||||
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
||||
use crate::bus::InboundMessage;
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata, InChatCommandHandler};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::storage::{SessionRecord, SessionStore};
|
||||
use crate::tools::task::repository::TaskRepository;
|
||||
use crate::agent::AgentError;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Local, TimeZone};
|
||||
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);
|
||||
|
||||
// 生成 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);
|
||||
@ -79,8 +80,7 @@ pub async fn save_session_to_file(
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
std::fs::write(&output_path, markdown)
|
||||
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
|
||||
Ok(output_path)
|
||||
}
|
||||
@ -134,9 +134,11 @@ impl CommandHandler for SaveSessionCommandHandler {
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
match cmd {
|
||||
Command::SaveSession { filepath, include_all, include_subagents } => {
|
||||
handle_save_session(self, filepath, include_all, include_subagents, ctx).await
|
||||
}
|
||||
Command::SaveSession {
|
||||
filepath,
|
||||
include_all,
|
||||
include_subagents,
|
||||
} => handle_save_session(self, filepath, include_all, include_subagents, ctx).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@ -199,13 +201,9 @@ async fn handle_save_session(
|
||||
|
||||
// 根据 include_all 获取消息数量
|
||||
let message_count = if include_all {
|
||||
handler
|
||||
.store
|
||||
.load_all_messages(session_id)
|
||||
handler.store.load_all_messages(session_id)
|
||||
} else {
|
||||
handler
|
||||
.store
|
||||
.load_messages(session_id)
|
||||
handler.store.load_messages(session_id)
|
||||
}
|
||||
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?
|
||||
.len();
|
||||
@ -215,9 +213,15 @@ async fn handle_save_session(
|
||||
MessageKind::Notification,
|
||||
// 路径中的反斜杠在 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()))
|
||||
}
|
||||
|
||||
@ -347,12 +351,18 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
|
||||
output.push_str("# Subagent Tasks\n\n");
|
||||
|
||||
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_str(&format!("**Task ID:** `{}`\n\n", task.task_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!("**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()));
|
||||
|
||||
// 子智能体消息
|
||||
@ -361,7 +371,10 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
|
||||
for (idx, msg) in task.messages.iter().enumerate() {
|
||||
output.push_str(&format!("#### Message {}\n\n", idx + 1));
|
||||
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 {
|
||||
output.push_str("**Reasoning:**\n");
|
||||
@ -676,7 +689,12 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
|
||||
inbound: &InboundMessage,
|
||||
session_manager: &crate::gateway::session::SessionManager,
|
||||
) -> 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);
|
||||
};
|
||||
|
||||
@ -707,7 +725,10 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
|
||||
// 返回成功或失败消息
|
||||
match result {
|
||||
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);
|
||||
Ok(Some(msg))
|
||||
}
|
||||
@ -774,7 +795,10 @@ mod tests {
|
||||
fn test_escape_yaml_string() {
|
||||
assert_eq!(escape_yaml_string("simple"), "simple");
|
||||
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]
|
||||
@ -835,14 +859,26 @@ mod tests {
|
||||
#[test]
|
||||
fn test_can_handle() {
|
||||
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 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 { filepath: None, include_all: true, include_subagents: false }));
|
||||
assert!(handler.can_handle(&Command::SaveSession {
|
||||
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::SaveTopic { filepath: None, include_subagents: false }));
|
||||
assert!(!handler.can_handle(&Command::SaveTopic {
|
||||
filepath: None,
|
||||
include_subagents: false
|
||||
}));
|
||||
}
|
||||
|
||||
/// 测试用的系统提示词提供者
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
||||
use crate::bus::ChatMessage;
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::handlers::{
|
||||
escape_yaml_string, format_timestamp, generate_messages_markdown,
|
||||
generate_subagent_tasks_markdown, generate_system_prompt_markdown,
|
||||
load_subagent_data, SubagentTaskData,
|
||||
SubagentTaskData, escape_yaml_string, format_timestamp, generate_messages_markdown,
|
||||
generate_subagent_tasks_markdown, generate_system_prompt_markdown, load_subagent_data,
|
||||
};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::storage::{SessionStore, TopicRecord};
|
||||
use crate::tools::task::repository::TaskRepository;
|
||||
use async_trait::async_trait;
|
||||
@ -63,8 +62,7 @@ pub async fn save_topic_to_file(
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
std::fs::write(&output_path, markdown)
|
||||
.map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
|
||||
Ok(output_path)
|
||||
}
|
||||
@ -210,9 +208,10 @@ impl CommandHandler for SaveTopicCommandHandler {
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
match cmd {
|
||||
Command::SaveTopic { filepath, include_subagents } => {
|
||||
handle_save_topic(self, filepath, include_subagents, ctx).await
|
||||
}
|
||||
Command::SaveTopic {
|
||||
filepath,
|
||||
include_subagents,
|
||||
} => handle_save_topic(self, filepath, include_subagents, ctx).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@ -249,14 +248,19 @@ async fn handle_save_topic(
|
||||
.store
|
||||
.get_topic(topic_id)
|
||||
.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
|
||||
.store
|
||||
.load_messages_for_topic(topic_id, Some(&topic_record.session_id))
|
||||
.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(
|
||||
@ -278,8 +282,14 @@ async fn handle_save_topic(
|
||||
MessageKind::Notification,
|
||||
// 路径中的反斜杠在 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()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::handlers::list_topics::TopicSummary;
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::gateway::session::SessionManager;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
@ -56,7 +56,9 @@ impl CommandHandler for SessionCommandHandler {
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
match cmd {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
@ -69,13 +71,16 @@ async fn handle_create_session(
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
// 获取当前 session_id,如果没有则报错
|
||||
let session_id = ctx.session_id.as_deref()
|
||||
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session. Please ensure a session exists first."))?;
|
||||
let session_id = ctx.session_id.as_deref().ok_or_else(|| {
|
||||
CommandError::new(
|
||||
"NO_SESSION",
|
||||
"No active session. Please ensure a session exists first.",
|
||||
)
|
||||
})?;
|
||||
|
||||
// 创建新话题(在同一个 Session 内)
|
||||
let topic_title = title.unwrap_or_else(|| {
|
||||
format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8])
|
||||
});
|
||||
let topic_title =
|
||||
title.unwrap_or_else(|| format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8]));
|
||||
|
||||
let topic = handler
|
||||
.store
|
||||
@ -83,14 +88,17 @@ async fn handle_create_session(
|
||||
.map_err(|e| CommandError::new("CREATE_TOPIC_ERROR", e.to_string()))?;
|
||||
|
||||
// 获取 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"))?;
|
||||
|
||||
// 如果有 SessionManager,自动切换到新话题
|
||||
if let Some(ref session_manager) = handler.session_manager {
|
||||
if let Some(session) = session_manager.get(&ctx.channel_name).await {
|
||||
let mut session_guard = session.lock().await;
|
||||
session_guard.switch_topic(chat_id, &topic.id)
|
||||
session_guard
|
||||
.switch_topic(chat_id, &topic.id)
|
||||
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::gateway::cancel_manager::CancelManager;
|
||||
use crate::gateway::session::SessionManager;
|
||||
|
||||
@ -15,7 +15,10 @@ pub struct StopExecutionCommandHandler {
|
||||
|
||||
impl StopExecutionCommandHandler {
|
||||
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 => {
|
||||
// 从 SessionManager 获取真实的 current topic
|
||||
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)) => {
|
||||
tracing::info!(
|
||||
channel = %ctx.channel_name,
|
||||
@ -65,12 +72,16 @@ impl CommandHandler for StopExecutionCommandHandler {
|
||||
id
|
||||
}
|
||||
Ok(None) => {
|
||||
return Ok(CommandResponse::success(ctx.request_id)
|
||||
.with_message(MessageKind::Notification, "当前没有活跃的话题,无法停止"));
|
||||
return Ok(CommandResponse::success(ctx.request_id).with_message(
|
||||
MessageKind::Notification,
|
||||
"当前没有活跃的话题,无法停止",
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(CommandResponse::error(ctx.request_id,
|
||||
CommandError::new("QUERY_TOPIC_ERROR", e.to_string())));
|
||||
return Ok(CommandResponse::error(
|
||||
ctx.request_id,
|
||||
CommandError::new("QUERY_TOPIC_ERROR", e.to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use crate::command::Command;
|
||||
use crate::command::context::CommandContext;
|
||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||
use crate::command::Command;
|
||||
use crate::gateway::session::SessionManager;
|
||||
use crate::storage::SessionStore;
|
||||
use async_trait::async_trait;
|
||||
@ -15,7 +15,10 @@ pub struct SwitchTopicCommandHandler {
|
||||
|
||||
impl SwitchTopicCommandHandler {
|
||||
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 {
|
||||
@ -44,9 +47,7 @@ impl CommandHandler for SwitchTopicCommandHandler {
|
||||
ctx: CommandContext,
|
||||
) -> Result<CommandResponse, CommandError> {
|
||||
match cmd {
|
||||
Command::SwitchTopic { topic_id } => {
|
||||
handle_switch_topic(self, topic_id, ctx).await
|
||||
}
|
||||
Command::SwitchTopic { topic_id } => handle_switch_topic(self, topic_id, ctx).await,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@ -57,9 +58,13 @@ async fn handle_switch_topic(
|
||||
topic_id: String,
|
||||
ctx: CommandContext,
|
||||
) -> 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"))?;
|
||||
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"))?;
|
||||
|
||||
// 尝试解析为序号
|
||||
@ -73,7 +78,11 @@ async fn handle_switch_topic(
|
||||
if index >= topics.len() {
|
||||
return Err(CommandError::new(
|
||||
"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()
|
||||
@ -86,19 +95,26 @@ async fn handle_switch_topic(
|
||||
.store
|
||||
.get_topic(&target_topic_id)
|
||||
.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,实际切换话题历史
|
||||
if let Some(ref session_manager) = handler.session_manager {
|
||||
if let Some(session) = session_manager.get(&ctx.channel_name).await {
|
||||
let mut session_guard = session.lock().await;
|
||||
session_guard.switch_topic(chat_id, &target_topic_id)
|
||||
session_guard
|
||||
.switch_topic(chat_id, &target_topic_id)
|
||||
.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)
|
||||
.unwrap_or(0);
|
||||
|
||||
|
||||
@ -48,10 +48,7 @@ pub enum Command {
|
||||
/// 列出所有定时任务
|
||||
ListSchedulerJobs,
|
||||
/// 加载指定 channel + chat_id 的对话消息
|
||||
LoadChatMessages {
|
||||
channel: String,
|
||||
chat_id: String,
|
||||
},
|
||||
LoadChatMessages { channel: String, chat_id: String },
|
||||
/// 删除指定话题
|
||||
DeleteTopic { topic_id: String },
|
||||
/// 重命名指定话题
|
||||
@ -67,10 +64,7 @@ pub enum Command {
|
||||
content: String,
|
||||
},
|
||||
/// 更新已有记忆
|
||||
UpdateMemory {
|
||||
id: String,
|
||||
content: String,
|
||||
},
|
||||
UpdateMemory { id: String, content: String },
|
||||
/// 删除记忆
|
||||
DeleteMemory { id: String },
|
||||
/// 列出所有技能
|
||||
|
||||
@ -261,7 +261,7 @@ fn default_task_enabled() -> bool {
|
||||
}
|
||||
|
||||
fn default_task_max_execution_secs() -> u64 {
|
||||
3600 // 60分钟
|
||||
3600 // 60分钟
|
||||
}
|
||||
|
||||
fn default_task_ttl_hours() -> u64 {
|
||||
@ -1061,7 +1061,10 @@ pub struct 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 }
|
||||
}
|
||||
|
||||
@ -1156,11 +1159,12 @@ fn resolve_env_placeholders(content: &str) -> String {
|
||||
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
||||
});
|
||||
|
||||
re_angle.replace_all(&content, |caps: ®ex::Captures| {
|
||||
let var_name = &caps[1];
|
||||
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
||||
})
|
||||
.to_string()
|
||||
re_angle
|
||||
.replace_all(&content, |caps: ®ex::Captures| {
|
||||
let var_name = &caps[1];
|
||||
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -1738,7 +1742,8 @@ mod tests {
|
||||
"allow_from": ["wxid_1"]
|
||||
}
|
||||
}
|
||||
}"#.replace("<CRED_PATH>", &cred_path_json),
|
||||
}"#
|
||||
.replace("<CRED_PATH>", &cred_path_json),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
@ -12,7 +12,9 @@ static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
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.
|
||||
@ -291,13 +293,20 @@ impl ExpertRuntime {
|
||||
|
||||
/// Re-discover experts from the filesystem.
|
||||
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(
|
||||
&config,
|
||||
&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();
|
||||
Ok(catalog)
|
||||
}
|
||||
@ -324,7 +333,11 @@ impl ExpertRuntime {
|
||||
|
||||
/// List all discovered experts including disabled ones, with their disabled scopes.
|
||||
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
|
||||
let config = self.config.read().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 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_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())?;
|
||||
if reload {
|
||||
let _ = self.reload()?;
|
||||
@ -457,7 +478,11 @@ impl ExpertRuntime {
|
||||
|
||||
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
|
||||
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);
|
||||
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.
|
||||
if self.get_expert(expert_name).is_none() {
|
||||
return Err(format!(
|
||||
"expert '{}' not found or disabled",
|
||||
expert_name
|
||||
));
|
||||
return Err(format!("expert '{}' not found or disabled", expert_name));
|
||||
}
|
||||
|
||||
{
|
||||
@ -624,10 +646,7 @@ impl SystemPromptProvider for ExpertPromptProvider {
|
||||
|
||||
let content = if expert.body.trim().is_empty() {
|
||||
// Empty body is OK; inject a header so the LLM still knows the role.
|
||||
format!(
|
||||
"# 专家角色: {}\n\n{}",
|
||||
expert.name, expert.description
|
||||
)
|
||||
format!("# 专家角色: {}\n\n{}", expert.name, expert.description)
|
||||
} else {
|
||||
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> {
|
||||
match scope {
|
||||
ExpertScope::User => user_experts_root()
|
||||
.ok_or_else(|| "failed to resolve home directory".to_string()),
|
||||
ExpertScope::User => {
|
||||
user_experts_root().ok_or_else(|| "failed to resolve home directory".to_string())
|
||||
}
|
||||
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
|
||||
/// 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 mut state = load_expert_state_file(&path)?;
|
||||
mutate(&mut state);
|
||||
@ -1097,7 +1120,11 @@ mod tests {
|
||||
let expert_dir = dir.path().join("demo");
|
||||
fs::create_dir_all(&expert_dir).unwrap();
|
||||
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();
|
||||
assert_eq!(expert.name, "demo");
|
||||
@ -1137,33 +1164,49 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_capability_policy_is_empty_helpers() {
|
||||
assert!(CapabilityPolicy::default().is_empty());
|
||||
assert!(!CapabilityPolicy {
|
||||
allowed_skills: Some(vec!["a".to_string()]),
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty());
|
||||
assert!(CapabilityPolicy {
|
||||
denied_tools: vec![],
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty());
|
||||
assert!(CapabilityPolicy {
|
||||
allowed_tools: Some(vec![]),
|
||||
..Default::default()
|
||||
}
|
||||
.has_tool_policy());
|
||||
assert!(CapabilityPolicy {
|
||||
denied_skills: vec!["x".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
.has_skill_policy());
|
||||
assert!(
|
||||
!CapabilityPolicy {
|
||||
allowed_skills: Some(vec!["a".to_string()]),
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
CapabilityPolicy {
|
||||
denied_tools: vec![],
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
CapabilityPolicy {
|
||||
allowed_tools: Some(vec![]),
|
||||
..Default::default()
|
||||
}
|
||||
.has_tool_policy()
|
||||
);
|
||||
assert!(
|
||||
CapabilityPolicy {
|
||||
denied_skills: vec!["x".to_string()],
|
||||
..Default::default()
|
||||
}
|
||||
.has_skill_policy()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1196,7 +1239,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_empty_capability_omits_keys() {
|
||||
// 空策略不应输出多余 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("denied_skills"));
|
||||
assert!(!rendered.contains("allowed_tools"));
|
||||
@ -1224,10 +1275,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
// project scope (overrides user)
|
||||
let project_dir_expert = project_dir
|
||||
.join(".picobot")
|
||||
.join("experts")
|
||||
.join("demo");
|
||||
let project_dir_expert = project_dir.join(".picobot").join("experts").join("demo");
|
||||
fs::create_dir_all(&project_dir_expert).unwrap();
|
||||
fs::write(
|
||||
project_dir_expert.join("EXPERT.md"),
|
||||
@ -1310,7 +1358,16 @@ mod tests {
|
||||
|
||||
// update with None preserves fields
|
||||
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();
|
||||
assert_eq!(updated_none.description, "更新翻译专家");
|
||||
assert_eq!(updated_none.body, "你是一名中文教师。");
|
||||
@ -1509,13 +1566,21 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
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].disabled_in_scopes, vec!["project".to_string()]);
|
||||
|
||||
// list_experts (filtered) should be empty
|
||||
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]
|
||||
@ -1526,7 +1591,9 @@ mod tests {
|
||||
session_experts: HashMap::new(),
|
||||
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();
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use gray_matter::engine::YAML;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Parse a markdown document with YAML frontmatter into `(frontmatter, body)`.
|
||||
@ -45,7 +45,13 @@ mod tests {
|
||||
fn parses_lf_endings() {
|
||||
let input = "---\ndescription: demo\n---\nbody text";
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@ -9,8 +9,8 @@ use crate::gateway::agent_prompt_provider::AgentPromptProvider;
|
||||
use crate::gateway::model_selection::ModelSelectionStore;
|
||||
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
||||
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
||||
use crate::storage::persistent_session_id;
|
||||
use crate::storage::PromptInjectionRepository;
|
||||
use crate::storage::persistent_session_id;
|
||||
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
|
||||
use crate::tools::{ToolContext, ToolRegistry};
|
||||
|
||||
@ -112,12 +112,14 @@ impl AgentFactory {
|
||||
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
|
||||
let expert_provider_config = match &expert {
|
||||
Some(e) if e.provider.is_some() || e.model.is_some() => {
|
||||
let resolved = self.model_resolver.resolve(
|
||||
e.provider.as_deref(),
|
||||
e.model.as_deref(),
|
||||
&request.provider_config,
|
||||
)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let resolved = self
|
||||
.model_resolver
|
||||
.resolve(
|
||||
e.provider.as_deref(),
|
||||
e.model.as_deref(),
|
||||
&request.provider_config,
|
||||
)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
tracing::info!(
|
||||
instance_id = self.instance_id,
|
||||
session_id = %session_id,
|
||||
@ -133,30 +135,29 @@ impl AgentFactory {
|
||||
|
||||
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
|
||||
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
|
||||
let effective_provider_config =
|
||||
match self.model_selections.get(&session_id) {
|
||||
Some((user_provider, user_model))
|
||||
if user_provider.is_some() || user_model.is_some() =>
|
||||
{
|
||||
let resolved = self
|
||||
.model_resolver
|
||||
.resolve(
|
||||
user_provider.as_deref(),
|
||||
user_model.as_deref(),
|
||||
&expert_provider_config,
|
||||
)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
tracing::info!(
|
||||
instance_id = self.instance_id,
|
||||
session_id = %session_id,
|
||||
provider = %resolved.name,
|
||||
model_id = %resolved.model_id,
|
||||
"AgentFactory: applied user model override"
|
||||
);
|
||||
resolved
|
||||
}
|
||||
_ => expert_provider_config,
|
||||
};
|
||||
let effective_provider_config = match self.model_selections.get(&session_id) {
|
||||
Some((user_provider, user_model))
|
||||
if user_provider.is_some() || user_model.is_some() =>
|
||||
{
|
||||
let resolved = self
|
||||
.model_resolver
|
||||
.resolve(
|
||||
user_provider.as_deref(),
|
||||
user_model.as_deref(),
|
||||
&expert_provider_config,
|
||||
)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
tracing::info!(
|
||||
instance_id = self.instance_id,
|
||||
session_id = %session_id,
|
||||
provider = %resolved.name,
|
||||
model_id = %resolved.model_id,
|
||||
"AgentFactory: applied user model override"
|
||||
);
|
||||
resolved
|
||||
}
|
||||
_ => expert_provider_config,
|
||||
};
|
||||
|
||||
// 诊断日志:记录 agent 实际使用的配置和实例 ID
|
||||
tracing::info!(
|
||||
|
||||
@ -40,7 +40,13 @@ impl AgentTaskExecutor {
|
||||
options: ScheduledAgentTaskOptions,
|
||||
) -> Result<Vec<OutboundMessage>, AgentError> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -93,8 +99,12 @@ impl SchedulerMaintenanceService {
|
||||
self.session_manager.cleanup_expired_sessions().await
|
||||
}
|
||||
|
||||
async fn run_memory_maintenance(&self) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
|
||||
self.session_manager.run_memory_maintenance_for_all_scopes().await
|
||||
async fn run_memory_maintenance(
|
||||
&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
|
||||
}
|
||||
|
||||
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()
|
||||
.await
|
||||
.map(|results| {
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::agent::{AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPromptContext};
|
||||
use crate::agent::{
|
||||
AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler,
|
||||
SystemPromptContext,
|
||||
};
|
||||
use crate::bus::message::ToolMessageState;
|
||||
use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
|
||||
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 super::compaction::schedule_background_history_compaction;
|
||||
@ -100,9 +103,12 @@ impl AgentExecutionService {
|
||||
};
|
||||
|
||||
if !is_current_turn {
|
||||
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) =
|
||||
session.stale_result_diagnostics(
|
||||
request.original_topic_id.as_deref().unwrap_or(request.chat_id),
|
||||
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = session
|
||||
.stale_result_diagnostics(
|
||||
request
|
||||
.original_topic_id
|
||||
.as_deref()
|
||||
.unwrap_or(request.chat_id),
|
||||
);
|
||||
tracing::info!(
|
||||
channel = %request.channel_name,
|
||||
@ -126,10 +132,9 @@ impl AgentExecutionService {
|
||||
if let Some(topic_id) = target_topic_id {
|
||||
if is_current_turn {
|
||||
// 话题未切换(current_topic == original_topic_id),安全更新内存历史
|
||||
if let Err(err) = session.append_persisted_messages(
|
||||
topic_id,
|
||||
request.result.emitted_messages.clone(),
|
||||
) {
|
||||
if let Err(err) = session
|
||||
.append_persisted_messages(topic_id, request.result.emitted_messages.clone())
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
topic_id = %topic_id,
|
||||
@ -153,10 +158,9 @@ impl AgentExecutionService {
|
||||
} else if is_current_turn {
|
||||
// 没有话题:直接更新内存历史(append_persisted_messages 会处理持久化)
|
||||
// 无 topic 场景用 chat_id 作为 topic_histories 的回退 key
|
||||
if let Err(err) = session.append_persisted_messages(
|
||||
request.chat_id,
|
||||
request.result.emitted_messages.clone(),
|
||||
) {
|
||||
if let Err(err) = session
|
||||
.append_persisted_messages(request.chat_id, request.result.emitted_messages.clone())
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
chat_id = %request.chat_id,
|
||||
@ -274,7 +278,13 @@ impl AgentExecutionService {
|
||||
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 的前一条消息处理完成(含压缩)
|
||||
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;
|
||||
|
||||
session_guard.ensure_persistent_session(request.chat_id)?;
|
||||
@ -382,12 +400,18 @@ impl AgentExecutionService {
|
||||
|
||||
// 获取 store 和 session_id,用于构造消息持久化 handler
|
||||
let store = session_guard.store();
|
||||
let session_id = crate::storage::persistent_session_id(
|
||||
request.channel_name,
|
||||
request.chat_id,
|
||||
);
|
||||
let session_id =
|
||||
crate::storage::persistent_session_id(request.channel_name, 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 来持久化消息
|
||||
@ -410,20 +434,21 @@ impl AgentExecutionService {
|
||||
|
||||
let result = agent.process(history, Some(&system_prompt_context)).await?;
|
||||
|
||||
let outbound_messages = self.finalize_result_and_schedule_compaction(
|
||||
request.session.clone(),
|
||||
FinalizeAgentResultRequest {
|
||||
channel_name: request.channel_name,
|
||||
chat_id: request.chat_id,
|
||||
user_message: &user_message,
|
||||
result,
|
||||
metadata: request.metadata,
|
||||
suppress_live_tool_calls: false,
|
||||
execution_kind: "scheduled_task",
|
||||
original_topic_id: original_topic_id.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let outbound_messages = self
|
||||
.finalize_result_and_schedule_compaction(
|
||||
request.session.clone(),
|
||||
FinalizeAgentResultRequest {
|
||||
channel_name: request.channel_name,
|
||||
chat_id: request.chat_id,
|
||||
user_message: &user_message,
|
||||
result,
|
||||
metadata: request.metadata,
|
||||
suppress_live_tool_calls: false,
|
||||
execution_kind: "scheduled_task",
|
||||
original_topic_id: original_topic_id.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 清理内存历史,释放内存(数据库历史保留)
|
||||
{
|
||||
@ -543,11 +568,7 @@ mod tests {
|
||||
let _guard1 = lock.lock().await;
|
||||
|
||||
// 第二次获取应阻塞,1ms 超时验证
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(1),
|
||||
lock.lock(),
|
||||
)
|
||||
.await;
|
||||
let result = tokio::time::timeout(std::time::Duration::from_millis(1), lock.lock()).await;
|
||||
|
||||
assert!(result.is_err(), "第二次获取同一锁应阻塞");
|
||||
}
|
||||
@ -561,11 +582,8 @@ mod tests {
|
||||
let _guard_a = lock_a.lock().await;
|
||||
|
||||
// 不同锁应立即可获取
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
lock_b.lock(),
|
||||
)
|
||||
.await;
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), lock_b.lock()).await;
|
||||
|
||||
assert!(result.is_ok(), "不同 topic 的锁应互不影响");
|
||||
}
|
||||
@ -585,11 +603,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// 锁应已释放,可再次获取
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
lock.lock(),
|
||||
)
|
||||
.await;
|
||||
let result = tokio::time::timeout(std::time::Duration::from_millis(100), lock.lock()).await;
|
||||
|
||||
assert!(result.is_ok(), "错误返回后锁应已释放");
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
use axum::{Json, extract::{Query, State}};
|
||||
use axum::http::StatusCode;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
@ -90,9 +93,7 @@ pub struct SaveConfigResponse {
|
||||
}
|
||||
|
||||
/// GET /api/config — Return current config with masked sensitive fields
|
||||
pub async fn get_config(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Json<Config> {
|
||||
pub async fn get_config(State(state): State<Arc<GatewayState>>) -> Json<Config> {
|
||||
Json(mask_config(&*state.config.read().await))
|
||||
}
|
||||
|
||||
@ -138,11 +139,19 @@ pub async fn save_config(
|
||||
.unwrap_or_else(|_| get_default_config_path());
|
||||
|
||||
// Serialize and write to disk (no lock held)
|
||||
let json = serde_json::to_string_pretty(&new_config)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialize error: {}", e)))?;
|
||||
let json = serde_json::to_string_pretty(&new_config).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Serialize error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
std::fs::write(&config_path, &json)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)))?;
|
||||
std::fs::write(&config_path, &json).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Write error: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 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
|
||||
pub async fn skills_list(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Json<SkillListResponse> {
|
||||
pub async fn skills_list(State(state): State<Arc<GatewayState>>) -> Json<SkillListResponse> {
|
||||
let skills_enabled = state.config.read().await.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.
|
||||
/// 通过 SessionManager::tools() 只读访问 ToolRegistry,不修改状态。
|
||||
pub async fn tools_list(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Json<ToolsListResponse> {
|
||||
pub async fn tools_list(State(state): State<Arc<GatewayState>>) -> Json<ToolsListResponse> {
|
||||
let registry = state.session_manager.tools();
|
||||
let tools: Vec<ToolInfo> = registry
|
||||
.get_definitions()
|
||||
@ -312,9 +317,7 @@ pub async fn tools_list(
|
||||
}
|
||||
|
||||
/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。
|
||||
pub async fn model_options(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Json<ModelOptionsResponse> {
|
||||
pub async fn model_options(State(state): State<Arc<GatewayState>>) -> Json<ModelOptionsResponse> {
|
||||
let config = state.config.read().await;
|
||||
let resolver = crate::config::ModelResolver::from_config(&config);
|
||||
// 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key)
|
||||
@ -371,7 +374,11 @@ pub async fn skills_toggle(
|
||||
changed: Some(change.changed),
|
||||
available: Some(change.available),
|
||||
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,
|
||||
}),
|
||||
@ -424,9 +431,7 @@ pub struct SubagentListResponse {
|
||||
}
|
||||
|
||||
/// GET /api/subagents — Return all discovered subagents with their disabled status
|
||||
pub async fn subagents_list(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Json<SubagentListResponse> {
|
||||
pub async fn subagents_list(State(state): State<Arc<GatewayState>>) -> Json<SubagentListResponse> {
|
||||
let subagents_enabled = state.config.read().await.subagents.enabled;
|
||||
|
||||
if !subagents_enabled {
|
||||
@ -725,9 +730,7 @@ pub struct ExpertDeleteResponse {
|
||||
}
|
||||
|
||||
/// GET /api/experts — Return all discovered experts with their disabled status
|
||||
pub async fn experts_list(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Json<ExpertListResponse> {
|
||||
pub async fn experts_list(State(state): State<Arc<GatewayState>>) -> Json<ExpertListResponse> {
|
||||
let experts_enabled = state.config.read().await.experts.enabled;
|
||||
|
||||
if !experts_enabled {
|
||||
@ -817,8 +820,12 @@ pub async fn experts_create(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<ExpertCreateRequest>,
|
||||
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
|
||||
let scope = ExpertScope::parse(&req.scope)
|
||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
|
||||
let scope = ExpertScope::parse(&req.scope).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid scope: {}", req.scope),
|
||||
)
|
||||
})?;
|
||||
|
||||
let expert = state
|
||||
.experts
|
||||
@ -849,8 +856,12 @@ pub async fn experts_update(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<ExpertUpdateRequest>,
|
||||
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
|
||||
let scope = ExpertScope::parse(&req.scope)
|
||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
|
||||
let scope = ExpertScope::parse(&req.scope).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid scope: {}", req.scope),
|
||||
)
|
||||
})?;
|
||||
|
||||
let expert = state
|
||||
.experts
|
||||
@ -1011,9 +1022,7 @@ pub async fn session_select_model(
|
||||
}
|
||||
drop(config);
|
||||
|
||||
state
|
||||
.model_selections
|
||||
.set(&req.session_id, provider, model);
|
||||
state.model_selections.set(&req.session_id, provider, model);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(SelectModelResponse {
|
||||
|
||||
@ -26,7 +26,7 @@ pub(crate) struct MemoryMaintenanceCandidate {
|
||||
pub(crate) namespace: String,
|
||||
pub(crate) key: String,
|
||||
pub(crate) content: String,
|
||||
pub(crate) updated_at: i64, // 记忆更新时间(Unix timestamp)
|
||||
pub(crate) updated_at: i64, // 记忆更新时间(Unix timestamp)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@ -227,8 +227,8 @@ impl MemoryMaintenanceService {
|
||||
Ok(parsed) => return Ok(parsed),
|
||||
Err(err) => {
|
||||
let error_msg = err.to_string();
|
||||
let is_truncated = error_msg.contains("EOF while parsing")
|
||||
|| error_msg.contains("expected");
|
||||
let is_truncated =
|
||||
error_msg.contains("EOF while parsing") || error_msg.contains("expected");
|
||||
|
||||
let should_retry = delay_ms.is_some() && is_truncated;
|
||||
last_error = Some(error_msg.clone());
|
||||
@ -369,9 +369,10 @@ impl MemoryMaintenanceService {
|
||||
pub(crate) async fn run_for_all_scopes(
|
||||
&self,
|
||||
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
|
||||
let scope_keys = self.store.list_memory_scope_keys("user").map_err(|err| {
|
||||
AgentError::Other(format!("list memory scope keys error: {}", err))
|
||||
})?;
|
||||
let scope_keys = self
|
||||
.store
|
||||
.list_memory_scope_keys("user")
|
||||
.map_err(|err| AgentError::Other(format!("list memory scope keys error: {}", err)))?;
|
||||
|
||||
if scope_keys.is_empty() {
|
||||
return Ok(None);
|
||||
@ -418,7 +419,8 @@ impl MemoryMaintenanceService {
|
||||
let managed_markdown = if all_remaining_memories.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.generate_summary("all", &all_remaining_memories).await?
|
||||
self.generate_summary("all", &all_remaining_memories)
|
||||
.await?
|
||||
};
|
||||
|
||||
if !managed_markdown.is_empty() {
|
||||
@ -678,24 +680,29 @@ pub(crate) fn validate_memory_maintenance_output(
|
||||
}
|
||||
|
||||
// 验证 2: 跨 namespace 合并检测(完全禁止)
|
||||
let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> = plan
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|c| (c.id.as_str(), c))
|
||||
.collect();
|
||||
let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> =
|
||||
plan.candidates.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||
|
||||
for merge in &output.merges {
|
||||
let source_namespaces: HashSet<&str> = merge
|
||||
.source_ids
|
||||
.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();
|
||||
|
||||
// 检查是否跨越多个 namespace
|
||||
if source_namespaces.len() > 1 {
|
||||
return Err(format!(
|
||||
"跨 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())
|
||||
.collect();
|
||||
|
||||
let deleted_ids: HashSet<&str> = output
|
||||
.low_value_ids
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
let deleted_ids: HashSet<&str> = output.low_value_ids.iter().map(|s| s.as_str()).collect();
|
||||
|
||||
let affected = merged_ids.len() + deleted_ids.len();
|
||||
let max_allowed = (total as f32 * max_merge_ratio).ceil() as usize;
|
||||
@ -758,8 +761,14 @@ pub(crate) fn apply_memory_maintenance_output(
|
||||
max_merge_per_group: usize,
|
||||
) -> Result<(), AgentError> {
|
||||
// 新增: 验证合并输出
|
||||
validate_memory_maintenance_output(plan, output, max_merge_ratio, min_memories_to_keep, max_merge_per_group)
|
||||
.map_err(|e| AgentError::Other(e))?;
|
||||
validate_memory_maintenance_output(
|
||||
plan,
|
||||
output,
|
||||
max_merge_ratio,
|
||||
min_memories_to_keep,
|
||||
max_merge_per_group,
|
||||
)
|
||||
.map_err(|e| AgentError::Other(e))?;
|
||||
|
||||
let all_candidates = plan.candidates.clone();
|
||||
|
||||
|
||||
@ -25,8 +25,8 @@ pub mod session_message_sender;
|
||||
pub mod session_message_service;
|
||||
pub mod session_pool;
|
||||
pub mod static_files;
|
||||
pub mod tool_registry_factory;
|
||||
pub mod tool_prompt_provider;
|
||||
pub mod tool_registry_factory;
|
||||
pub mod ws;
|
||||
|
||||
use axum::{Router, routing};
|
||||
@ -50,11 +50,11 @@ use cancel_manager::CancelManager;
|
||||
use outbound_dispatcher::OutboundDispatcher;
|
||||
use processor::InboundProcessor;
|
||||
use runtime::build_session_manager_with_sender;
|
||||
use session_message_sender::BusSessionMessageSender;
|
||||
use session::SessionManager;
|
||||
use session_message_sender::BusSessionMessageSender;
|
||||
use static_files::static_handler;
|
||||
|
||||
use tokio::sync::{watch, RwLock};
|
||||
use tokio::sync::{RwLock, watch};
|
||||
|
||||
pub struct GatewayState {
|
||||
pub config: Arc<RwLock<Config>>,
|
||||
@ -73,7 +73,10 @@ pub struct 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
|
||||
let provider_config = config.get_provider_config("default")?;
|
||||
let mut provider_configs = HashMap::<String, LLMProviderConfig>::new();
|
||||
@ -87,7 +90,9 @@ impl GatewayState {
|
||||
let session_ttl_hours = config.gateway.session_ttl_hours;
|
||||
|
||||
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 bus = channel_manager.bus();
|
||||
|
||||
@ -95,24 +100,25 @@ impl GatewayState {
|
||||
mcp_servers: config.mcp_servers.clone(),
|
||||
};
|
||||
|
||||
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) = build_session_manager_with_sender(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
config.time.timezone.clone(),
|
||||
provider_config,
|
||||
provider_configs,
|
||||
skills.clone(),
|
||||
experts.clone(),
|
||||
Arc::new(BusSessionMessageSender::new(bus.clone())),
|
||||
std::collections::HashSet::new(),
|
||||
config.tools.task.clone(),
|
||||
config.subagents.clone(),
|
||||
config.memory_maintenance.clone(),
|
||||
session_ttl_hours,
|
||||
mcp_config,
|
||||
Some(bus.clone()),
|
||||
Arc::new(crate::config::ModelResolver::from_config(&config)),
|
||||
)?;
|
||||
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) =
|
||||
build_session_manager_with_sender(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
config.time.timezone.clone(),
|
||||
provider_config,
|
||||
provider_configs,
|
||||
skills.clone(),
|
||||
experts.clone(),
|
||||
Arc::new(BusSessionMessageSender::new(bus.clone())),
|
||||
std::collections::HashSet::new(),
|
||||
config.tools.task.clone(),
|
||||
config.subagents.clone(),
|
||||
config.memory_maintenance.clone(),
|
||||
session_ttl_hours,
|
||||
mcp_config,
|
||||
Some(bus.clone()),
|
||||
Arc::new(crate::config::ModelResolver::from_config(&config)),
|
||||
)?;
|
||||
|
||||
// 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态)
|
||||
tracing::info!(
|
||||
@ -155,8 +161,13 @@ impl GatewayState {
|
||||
drop(cfg); // release read lock before spawning long-running tasks
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(max_concurrent));
|
||||
let inbound_processor =
|
||||
InboundProcessor::new(self.bus.clone(), self.session_manager.clone(), semaphore, provider_config, self.cancel_manager.clone());
|
||||
let inbound_processor = InboundProcessor::new(
|
||||
self.bus.clone(),
|
||||
self.session_manager.clone(),
|
||||
semaphore,
|
||||
provider_config,
|
||||
self.cancel_manager.clone(),
|
||||
);
|
||||
tokio::spawn(inbound_processor.run());
|
||||
|
||||
// Spawn outbound dispatcher
|
||||
@ -241,7 +252,10 @@ pub async fn run(
|
||||
let app = if use_embedded {
|
||||
Router::new()
|
||||
.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/mcp/status", routing::get(http::mcp_status))
|
||||
.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/model-options", routing::get(http::model_options))
|
||||
.route("/api/subagents", routing::get(http::subagents_list))
|
||||
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
||||
.route("/api/subagents/update", routing::put(http::subagents_update))
|
||||
.route(
|
||||
"/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/toggle", routing::post(http::experts_toggle))
|
||||
.route("/api/experts/create", routing::post(http::experts_create))
|
||||
.route("/api/experts/update", routing::put(http::experts_update))
|
||||
.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/session/select-model", routing::post(http::session_select_model))
|
||||
.route("/api/session/selected-model", routing::get(http::session_selected_model))
|
||||
.route(
|
||||
"/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))
|
||||
.fallback(static_handler)
|
||||
.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());
|
||||
Router::new()
|
||||
.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/mcp/status", routing::get(http::mcp_status))
|
||||
.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/model-options", routing::get(http::model_options))
|
||||
.route("/api/subagents", routing::get(http::subagents_list))
|
||||
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
||||
.route("/api/subagents/update", routing::put(http::subagents_update))
|
||||
.route(
|
||||
"/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/toggle", routing::post(http::experts_toggle))
|
||||
.route("/api/experts/create", routing::post(http::experts_create))
|
||||
.route("/api/experts/update", routing::put(http::experts_update))
|
||||
.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/session/select-model", routing::post(http::session_select_model))
|
||||
.route("/api/session/selected-model", routing::get(http::session_selected_model))
|
||||
.route(
|
||||
"/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))
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(state.clone())
|
||||
|
||||
@ -16,12 +16,7 @@ impl ModelSelectionStore {
|
||||
}
|
||||
|
||||
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
|
||||
pub fn set(
|
||||
&self,
|
||||
session_id: &str,
|
||||
provider: Option<String>,
|
||||
model: Option<String>,
|
||||
) {
|
||||
pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
|
||||
let mut selections = self
|
||||
.selections
|
||||
.write()
|
||||
@ -76,9 +71,6 @@ mod tests {
|
||||
fn set_only_provider_keeps_entry() {
|
||||
let store = ModelSelectionStore::new();
|
||||
store.set("s1", Some("p1".to_string()), None);
|
||||
assert_eq!(
|
||||
store.get("s1"),
|
||||
Some((Some("p1".to_string()), None))
|
||||
);
|
||||
assert_eq!(store.get("s1"), Some((Some("p1".to_string()), None)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
|
||||
use crate::config::LLMProviderConfig;
|
||||
use crate::gateway::agent_factory::build_system_prompt_provider;
|
||||
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::topic_description::generate_topic_description;
|
||||
|
||||
@ -52,8 +52,8 @@ impl InboundProcessor {
|
||||
let store = session_manager.store();
|
||||
|
||||
// 注册 Session 处理器
|
||||
let session_handler = SessionCommandHandler::new(store.clone())
|
||||
.with_session_manager(session_manager.clone());
|
||||
let session_handler =
|
||||
SessionCommandHandler::new(store.clone()).with_session_manager(session_manager.clone());
|
||||
command_router.register(Box::new(session_handler));
|
||||
|
||||
// 注册 list_sessions 处理器
|
||||
@ -79,7 +79,7 @@ impl InboundProcessor {
|
||||
// 注册 get_current 处理器
|
||||
command_router.register(Box::new(
|
||||
GetCurrentSessionCommandHandler::new(store.clone())
|
||||
.with_system_prompt_provider(system_prompt_provider.clone())
|
||||
.with_system_prompt_provider(system_prompt_provider.clone()),
|
||||
));
|
||||
|
||||
// 注册 load_topic 处理器
|
||||
@ -185,7 +185,8 @@ impl InboundProcessor {
|
||||
let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id);
|
||||
|
||||
// 获取当前话题(封装了 session 创建逻辑)
|
||||
let current_topic = self.session_manager
|
||||
let current_topic = self
|
||||
.session_manager
|
||||
.get_current_topic(&inbound.channel, &inbound.chat_id)
|
||||
.await?;
|
||||
|
||||
@ -196,15 +197,19 @@ impl InboundProcessor {
|
||||
|
||||
if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) {
|
||||
// 使用命令路由器处理
|
||||
let mut cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
|
||||
.with_session_id(&session_id)
|
||||
.with_chat_id(&inbound.chat_id);
|
||||
let mut cmd_ctx =
|
||||
crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
|
||||
.with_session_id(&session_id)
|
||||
.with_chat_id(&inbound.chat_id);
|
||||
// 只在有话题时才设置 topic_id
|
||||
if let Some(ref topic_id) = current_topic {
|
||||
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 {
|
||||
@ -295,7 +300,9 @@ impl InboundProcessor {
|
||||
outbound.metadata.extend(inbound.forwarded_metadata.clone());
|
||||
// 注入 topic_id 到 outbound metadata,用于前端按话题隔离消息
|
||||
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 {
|
||||
tracing::error!(error = %error, "Failed to publish outbound");
|
||||
@ -306,10 +313,17 @@ impl InboundProcessor {
|
||||
if let Some(ref topic_id) = current_topic {
|
||||
let store = self.session_manager.store();
|
||||
if let Ok(Some(topic)) = store.get_topic(topic_id) {
|
||||
if topic.description.is_none() || 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 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) {
|
||||
false
|
||||
} else {
|
||||
@ -329,7 +343,9 @@ impl InboundProcessor {
|
||||
let first_user_message = store_clone
|
||||
.load_messages_for_topic(&topic_id_clone, None)
|
||||
.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);
|
||||
|
||||
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) {
|
||||
match generate_topic_description(provider.as_ref(), &message_content).await {
|
||||
match generate_topic_description(
|
||||
provider.as_ref(),
|
||||
&message_content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(description) => {
|
||||
if let Err(e) = store_clone.update_topic_description(&topic_id_clone, &description) {
|
||||
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");
|
||||
} else {
|
||||
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
|
||||
|
||||
@ -57,8 +57,9 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result<Option<String>,
|
||||
ensure_parent_dir(path)?;
|
||||
// 文件不存在时创建空白模板
|
||||
if !path.exists() {
|
||||
fs::write(path, template)
|
||||
.map_err(|err| AgentError::Other(format!("create AGENT.md template error: {}", err)))?;
|
||||
fs::write(path, template).map_err(|err| {
|
||||
AgentError::Other(format!("create AGENT.md template error: {}", err))
|
||||
})?;
|
||||
}
|
||||
// 读取内容,仅当非空(去除注释后)时注入
|
||||
let content = fs::read_to_string(path)
|
||||
@ -70,8 +71,9 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result<Option<String>,
|
||||
}
|
||||
PromptSource::AutoGenerated(path) => {
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|err| AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err)))?;
|
||||
let content = fs::read_to_string(path).map_err(|err| {
|
||||
AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err))
|
||||
})?;
|
||||
let without_comments = strip_comments_and_whitespace(&content);
|
||||
if !without_comments.is_empty() {
|
||||
fragments.push(without_comments);
|
||||
@ -337,6 +339,9 @@ mod tests {
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,9 @@ use tokio::sync::RwLock;
|
||||
|
||||
use crate::agent::AgentError;
|
||||
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::tool_registry_factory::ToolRegistryFactory;
|
||||
use crate::mcp::McpInitializer;
|
||||
@ -18,13 +20,13 @@ use crate::storage::{
|
||||
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
|
||||
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::runtime::SubagentRuntime;
|
||||
use crate::tools::todo_write::TodoItem;
|
||||
use crate::tools::{
|
||||
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, SessionMessageSender,
|
||||
SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
|
||||
};
|
||||
|
||||
use super::agent_factory::AgentFactory;
|
||||
use super::cli_session::CliSessionService;
|
||||
@ -55,7 +57,16 @@ pub(crate) fn build_session_manager(
|
||||
mcp_config: crate::mcp::McpConfig,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
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(
|
||||
agent_prompt_reinject_every,
|
||||
show_tool_results,
|
||||
@ -94,7 +105,16 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
mcp_config: crate::mcp::McpConfig,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
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(
|
||||
SessionStore::new()
|
||||
.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)
|
||||
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());
|
||||
// Build subagent tools with MCP tools (task tool registered separately below)
|
||||
let subagent_tools = Arc::new(
|
||||
factory.build_subagent_tools(
|
||||
if mcp_tools_for_subagents.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(mcp_tools_for_subagents.clone())
|
||||
}
|
||||
)
|
||||
);
|
||||
let subagent_tools = Arc::new(factory.build_subagent_tools(
|
||||
if mcp_tools_for_subagents.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(mcp_tools_for_subagents.clone())
|
||||
},
|
||||
));
|
||||
|
||||
// Create subagent catalog with discovery, wrap in SubagentRuntime
|
||||
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 {
|
||||
// task_config 未启用时仍创建 subagent_runtime(供 API 使用)
|
||||
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
|
||||
@ -306,18 +336,24 @@ pub(crate) fn build_session_manager_with_sender(
|
||||
// Extract MCP manager for lifecycle management (e.g., disconnect on restart)
|
||||
let mcp_manager = mcp_initializer.manager();
|
||||
|
||||
Ok((SessionManager::from_services(SessionManagerServices {
|
||||
tools: tools as Arc<ToolRegistry>,
|
||||
skills,
|
||||
experts,
|
||||
subagent_runtime: subagent_runtime.clone(),
|
||||
store,
|
||||
show_tool_results,
|
||||
lifecycle,
|
||||
cli_sessions,
|
||||
messages,
|
||||
scheduled_tasks,
|
||||
memory_maintenance,
|
||||
task_repository: task_repository.clone(),
|
||||
}), task_repository, mcp_manager, subagent_runtime, model_selections))
|
||||
Ok((
|
||||
SessionManager::from_services(SessionManagerServices {
|
||||
tools: tools as Arc<ToolRegistry>,
|
||||
skills,
|
||||
experts,
|
||||
subagent_runtime: subagent_runtime.clone(),
|
||||
store,
|
||||
show_tool_results,
|
||||
lifecycle,
|
||||
cli_sessions,
|
||||
messages,
|
||||
scheduled_tasks,
|
||||
memory_maintenance,
|
||||
task_repository: task_repository.clone(),
|
||||
}),
|
||||
task_repository,
|
||||
mcp_manager,
|
||||
subagent_runtime,
|
||||
model_selections,
|
||||
))
|
||||
}
|
||||
|
||||
@ -37,7 +37,10 @@ impl ScheduledAgentTaskService {
|
||||
// 根据 chat_id 自动选择 Session:
|
||||
// - scheduler/ 开头:使用定时任务专用 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
|
||||
.sender_id
|
||||
.clone()
|
||||
|
||||
@ -2,12 +2,15 @@ use crate::agent::{AgentError, AgentLoop, ContextCompressor, EmittedMessageHandl
|
||||
#[cfg(test)]
|
||||
use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT;
|
||||
use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
|
||||
use crate::providers::StreamDelta;
|
||||
use crate::config::LLMProviderConfig;
|
||||
use crate::protocol::WsOutbound;
|
||||
use crate::providers::StreamDelta;
|
||||
use crate::scheduler::ScheduledAgentTaskOptions;
|
||||
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::task::repository::TaskRepository;
|
||||
use crate::tools::task::runtime::SubagentRuntime;
|
||||
@ -24,8 +27,7 @@ use super::execution::should_display_message_to_user;
|
||||
#[cfg(test)]
|
||||
use super::memory_maintenance::{
|
||||
MemoryMaintenanceMerge, apply_memory_maintenance_output, build_memory_maintenance_plan,
|
||||
extract_json_object, is_recoverable_maintenance_llm_error,
|
||||
strip_json_code_fence,
|
||||
extract_json_object, is_recoverable_maintenance_llm_error, strip_json_code_fence,
|
||||
};
|
||||
use super::memory_maintenance::{MemoryMaintenanceScopeResult, MemoryOrganizationOutput};
|
||||
use super::memory_maintenance_coordinator::MemoryMaintenanceCoordinator;
|
||||
@ -125,7 +127,9 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
||||
// Get or create the stream message ID
|
||||
let message_id = {
|
||||
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
|
||||
@ -180,7 +184,11 @@ impl BusToolCallEmitter {
|
||||
.cloned()
|
||||
.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()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@ -304,11 +312,7 @@ impl Session {
|
||||
skills,
|
||||
agent_factory,
|
||||
compressor: ContextCompressor::from_provider_config(&provider_config),
|
||||
history: SessionHistory::new(
|
||||
channel_name,
|
||||
conversations,
|
||||
skill_events,
|
||||
),
|
||||
history: SessionHistory::new(channel_name, conversations, skill_events),
|
||||
store,
|
||||
pending_cancel_tokens: HashMap::new(),
|
||||
})
|
||||
@ -586,7 +590,7 @@ impl Session {
|
||||
) -> Result<AgentLoop, AgentError> {
|
||||
self.create_agent_with_provider_config(
|
||||
chat_id,
|
||||
None, // notification_chat_id = None,使用 session_chat_id
|
||||
None, // notification_chat_id = None,使用 session_chat_id
|
||||
sender_id,
|
||||
message_id,
|
||||
self.provider_config.clone(),
|
||||
@ -611,7 +615,9 @@ impl Session {
|
||||
// 消费 pending 的取消信号接收端(如果存在)
|
||||
// 优先按 topic_id 查找;无 topic 时回退 chat_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)),
|
||||
None => self.pending_cancel_tokens.remove(session_chat_id),
|
||||
};
|
||||
@ -780,7 +786,11 @@ impl SessionManager {
|
||||
}
|
||||
|
||||
/// 获取指定 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?;
|
||||
if let Some(session) = self.get(channel_name).await {
|
||||
let mut guard = session.lock().await;
|
||||
@ -788,7 +798,9 @@ impl SessionManager {
|
||||
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题
|
||||
if guard.current_topic(chat_id).is_none() {
|
||||
let session_id = guard.persistent_session_id(chat_id);
|
||||
let topics = self.store.list_topics(&session_id)
|
||||
let topics = self
|
||||
.store
|
||||
.list_topics(&session_id)
|
||||
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
|
||||
|
||||
if let Some(latest_topic) = topics.first() {
|
||||
@ -802,10 +814,7 @@ impl SessionManager {
|
||||
);
|
||||
} else {
|
||||
// 数据库中也没有话题,自动创建默认话题
|
||||
let title = format!(
|
||||
"话题 {}",
|
||||
chrono::Local::now().format("%m/%d %H:%M")
|
||||
);
|
||||
let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M"));
|
||||
match self.store.create_topic(&session_id, &title, None) {
|
||||
Ok(topic) => {
|
||||
guard.set_current_topic(chat_id, Some(topic.id.clone()));
|
||||
@ -845,7 +854,10 @@ impl SessionManager {
|
||||
token: tokio::sync::watch::Receiver<()>,
|
||||
) {
|
||||
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,
|
||||
) -> Result<Vec<OutboundMessage>, AgentError> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -972,9 +990,9 @@ mod tests {
|
||||
store.clone(),
|
||||
store.clone(),
|
||||
Arc::new(NoopSessionMessageSender),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
"Asia/Shanghai".to_string(),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
Default::default(),
|
||||
)
|
||||
.build(),
|
||||
@ -1000,12 +1018,16 @@ mod tests {
|
||||
|
||||
let first = session.create_user_message("first", Vec::new());
|
||||
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));
|
||||
|
||||
let second = session.create_user_message("second", Vec::new());
|
||||
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, &second_id));
|
||||
@ -1024,9 +1046,9 @@ mod tests {
|
||||
store.clone(),
|
||||
store.clone(),
|
||||
Arc::new(NoopSessionMessageSender),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
"Asia/Shanghai".to_string(),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
Default::default(),
|
||||
)
|
||||
.build(),
|
||||
@ -1052,9 +1074,15 @@ mod tests {
|
||||
|
||||
let first = session.create_user_message("first", Vec::new());
|
||||
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), 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();
|
||||
|
||||
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())
|
||||
.unwrap();
|
||||
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();
|
||||
|
||||
let preserved_messages = session.get_history(&topic_id).unwrap().clone();
|
||||
@ -1235,7 +1267,15 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
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
|
||||
.unwrap();
|
||||
|
||||
@ -1733,7 +1773,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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!({
|
||||
"user_facts": ["用户在做AI产品"],
|
||||
"preferences": [],
|
||||
@ -1983,11 +2024,18 @@ mod tests {
|
||||
|
||||
let all_memories = store.list_memories_for_scope("user", scope_key).unwrap();
|
||||
// 过滤掉 _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 条
|
||||
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]
|
||||
@ -2010,22 +2058,19 @@ mod tests {
|
||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||
let bus = MessageBus::new(4);
|
||||
let emitter =
|
||||
BusToolCallEmitter::new(
|
||||
bus.clone(),
|
||||
"test-channel",
|
||||
"chat-1",
|
||||
HashMap::new(),
|
||||
store,
|
||||
);
|
||||
BusToolCallEmitter::new(bus.clone(), "test-channel", "chat-1", HashMap::new(), store);
|
||||
|
||||
emitter
|
||||
.handle(ChatMessage::tool("call-1", "calculator", "2"))
|
||||
.await;
|
||||
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_millis(500), bus.consume_outbound())
|
||||
.await
|
||||
.expect("timeout waiting for outbound message")
|
||||
.expect("bus outbound closed");
|
||||
let msg = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(500),
|
||||
bus.consume_outbound(),
|
||||
)
|
||||
.await
|
||||
.expect("timeout waiting for outbound message")
|
||||
.expect("bus outbound closed");
|
||||
assert_eq!(msg.event_kind, OutboundEventKind::ToolResult);
|
||||
}
|
||||
|
||||
@ -2042,9 +2087,9 @@ mod tests {
|
||||
store.clone(),
|
||||
store.clone(),
|
||||
Arc::new(NoopSessionMessageSender),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
"Asia/Shanghai".to_string(),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
Default::default(),
|
||||
)
|
||||
.build(),
|
||||
@ -2083,9 +2128,9 @@ mod tests {
|
||||
store.clone(),
|
||||
store.clone(),
|
||||
Arc::new(NoopSessionMessageSender),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
"Asia/Shanghai".to_string(),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
Default::default(),
|
||||
)
|
||||
.build(),
|
||||
@ -2111,7 +2156,11 @@ mod tests {
|
||||
|
||||
for turn in 0..100 {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -2160,9 +2209,9 @@ mod tests {
|
||||
store.clone(),
|
||||
store.clone(),
|
||||
Arc::new(NoopSessionMessageSender),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
"Asia/Shanghai".to_string(),
|
||||
HashSet::new(),
|
||||
HashSet::new(),
|
||||
Default::default(),
|
||||
)
|
||||
.build(),
|
||||
@ -2188,7 +2237,11 @@ mod tests {
|
||||
|
||||
for turn in 0..100 {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@ -115,7 +115,9 @@ impl SessionHistory {
|
||||
}
|
||||
|
||||
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>> {
|
||||
|
||||
@ -52,9 +52,12 @@ impl SessionLifecycleService {
|
||||
channel_name: &str,
|
||||
chat_id: &str,
|
||||
) -> 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.session_pool.get_for_chat_id(channel_name, chat_id)
|
||||
self.session_pool
|
||||
.get_for_chat_id(channel_name, chat_id)
|
||||
.await
|
||||
.ok_or_else(|| AgentError::Other("Session not found".to_string()))
|
||||
}
|
||||
|
||||
@ -122,7 +122,7 @@ mod tests {
|
||||
// 使用临时目录确保跨平台兼容
|
||||
attachments: vec![MediaItem::new(
|
||||
&std::env::temp_dir().join("demo.png").display().to_string(),
|
||||
"image"
|
||||
"image",
|
||||
)],
|
||||
},
|
||||
)
|
||||
@ -143,4 +143,4 @@ mod tests {
|
||||
assert_eq!(msg.media.len(), 1);
|
||||
assert_eq!(msg.media[0].media_type, "image");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,7 +49,10 @@ impl SessionPool {
|
||||
}
|
||||
|
||||
/// 确保定时任务专用 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
|
||||
}
|
||||
|
||||
@ -59,7 +62,11 @@ impl SessionPool {
|
||||
/// session 创建(含配置加载、agent 工厂构造),再次持锁插入并处理竞态。
|
||||
/// 避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的
|
||||
/// 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: 已存在直接返回(短暂持锁)
|
||||
{
|
||||
let inner = self.inner.lock().await;
|
||||
@ -109,14 +116,26 @@ impl SessionPool {
|
||||
}
|
||||
|
||||
/// 获取定时任务专用 Session
|
||||
pub(crate) async fn get_scheduler_session(&self, channel_name: &str) -> Option<Arc<Mutex<Session>>> {
|
||||
self.inner.lock().await.scheduler_sessions.get(channel_name).cloned()
|
||||
pub(crate) async fn get_scheduler_session(
|
||||
&self,
|
||||
channel_name: &str,
|
||||
) -> Option<Arc<Mutex<Session>>> {
|
||||
self.inner
|
||||
.lock()
|
||||
.await
|
||||
.scheduler_sessions
|
||||
.get(channel_name)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 根据 chat_id 自动选择 Session
|
||||
/// - scheduler/ 开头:返回定时任务专用 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) {
|
||||
self.get_scheduler_session(channel_name).await
|
||||
} else {
|
||||
@ -125,7 +144,11 @@ impl SessionPool {
|
||||
}
|
||||
|
||||
/// 确保 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) {
|
||||
self.ensure_scheduler_session(channel_name).await
|
||||
} else {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, Response, StatusCode, Uri},
|
||||
http::{Response, StatusCode, Uri, header},
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
@ -16,11 +16,7 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
|
||||
// 处理根路径,返回 index.html
|
||||
let path = if path.is_empty() {
|
||||
"index.html"
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let path = if path.is_empty() { "index.html" } else { path };
|
||||
|
||||
match StaticAssets::get(path) {
|
||||
Some(content) => {
|
||||
@ -54,4 +50,4 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,13 +6,14 @@ use tokio::sync::RwLock;
|
||||
use crate::config::TaskConfig;
|
||||
use crate::mcp::McpClientManager;
|
||||
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::{
|
||||
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
|
||||
HttpRequestTool, MemoryManageTool, MemorySearchTool,
|
||||
SchedulerManageTool, SessionMessageSender, SessionSendTool, ShellSessionManager,
|
||||
SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
|
||||
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, HttpRequestTool,
|
||||
MemoryManageTool, MemorySearchTool, SchedulerManageTool, SessionMessageSender, SessionSendTool,
|
||||
ShellSessionManager, SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
|
||||
TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool,
|
||||
};
|
||||
|
||||
@ -72,18 +73,12 @@ impl ToolRegistryFactory {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_subagent_runtime(
|
||||
mut self,
|
||||
runtime: Arc<dyn SubAgentRuntime>,
|
||||
) -> Self {
|
||||
pub(crate) fn with_subagent_runtime(mut self, runtime: Arc<dyn SubAgentRuntime>) -> Self {
|
||||
self.subagent_runtime = Some(runtime);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_mcp_manager(
|
||||
mut self,
|
||||
manager: Arc<McpClientManager>,
|
||||
) -> Self {
|
||||
pub(crate) fn with_mcp_manager(mut self, manager: Arc<McpClientManager>) -> Self {
|
||||
self.mcp_manager = Some(manager);
|
||||
self
|
||||
}
|
||||
@ -118,8 +113,14 @@ impl ToolRegistryFactory {
|
||||
}
|
||||
if self.is_enabled("todo_write") {
|
||||
if let Some(ref state) = self.todo_state {
|
||||
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
|
||||
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
|
||||
registry.register(TodoWriteTool::new(
|
||||
state.clone(),
|
||||
self.todo_repository.clone(),
|
||||
));
|
||||
registry.register(TodoReadTool::new(
|
||||
state.clone(),
|
||||
self.todo_repository.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.is_enabled("session_send") {
|
||||
@ -226,8 +227,14 @@ impl ToolRegistryFactory {
|
||||
// Todo 追踪工具
|
||||
if self.is_enabled("todo_write") {
|
||||
if let Some(ref state) = self.todo_state {
|
||||
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
|
||||
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
|
||||
registry.register(TodoWriteTool::new(
|
||||
state.clone(),
|
||||
self.todo_repository.clone(),
|
||||
));
|
||||
registry.register(TodoReadTool::new(
|
||||
state.clone(),
|
||||
self.todo_repository.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,16 +10,16 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
|
||||
use crate::command::handlers::help::HelpCommandHandler;
|
||||
use crate::command::handlers::list_channels::ListChannelsCommandHandler;
|
||||
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_todos::ListTodosCommandHandler;
|
||||
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
|
||||
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
||||
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::load_chat_messages::LoadChatMessagesCommandHandler;
|
||||
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
|
||||
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::save_session::SaveSessionCommandHandler;
|
||||
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::switch_topic::SwitchTopicCommandHandler;
|
||||
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::tools::task::repository::TaskRepository;
|
||||
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
|
||||
/// 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() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@ -82,15 +84,16 @@ fn process_attachments_with_base64(attachments: Vec<MediaSummary>) -> Result<Vec
|
||||
.map(|att| {
|
||||
// If content_base64 exists, save to file and update path
|
||||
if let Some(base64_content) = &att.content_base64 {
|
||||
let decoded = STANDARD
|
||||
.decode(base64_content)
|
||||
.map_err(|error| AgentError::Other(format!("Failed to decode base64: {}", error)))?;
|
||||
let decoded = STANDARD.decode(base64_content).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 file_path = media_dir.join(&filename);
|
||||
|
||||
std::fs::write(&file_path, decoded)
|
||||
.map_err(|error| AgentError::Other(format!("Failed to write media file: {}", error)))?;
|
||||
std::fs::write(&file_path, decoded).map_err(|error| {
|
||||
AgentError::Other(format!("Failed to write media file: {}", error))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
filename = %filename,
|
||||
@ -136,10 +139,8 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
||||
let store = state.session_manager.store();
|
||||
|
||||
// 1. 查询 websocket 和 cli 两个通道的 Sessions(兼容旧版本 cli 通道创建的会话)
|
||||
let mut websocket_sessions = store.list_sessions("websocket", false)
|
||||
.unwrap_or_default();
|
||||
let cli_channel_sessions = store.list_sessions("cli", false)
|
||||
.unwrap_or_default();
|
||||
let mut websocket_sessions = store.list_sessions("websocket", false).unwrap_or_default();
|
||||
let cli_channel_sessions = store.list_sessions("cli", false).unwrap_or_default();
|
||||
websocket_sessions.extend(cli_channel_sessions);
|
||||
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 动态通道)
|
||||
let channels = state.channel_manager.build_channel_list().await;
|
||||
let _ = sender
|
||||
.send(WsOutbound::ChannelList { channels })
|
||||
.await;
|
||||
let _ = sender.send(WsOutbound::ChannelList { channels }).await;
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
|
||||
async fn handle_inbound(
|
||||
state: &Arc<GatewayState>,
|
||||
sender: &mpsc::Sender<WsOutbound>,
|
||||
@ -394,7 +392,11 @@ async fn handle_inbound(
|
||||
let store = state.session_manager.store();
|
||||
let skills = state.session_manager.skills();
|
||||
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()))?;
|
||||
let prompt_repository = state.session_manager.store().clone();
|
||||
|
||||
@ -417,9 +419,13 @@ async fn handle_inbound(
|
||||
// 注册 list_sessions 处理器
|
||||
router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
|
||||
// 注册 list_sessions_by_channel 处理器
|
||||
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone())));
|
||||
router.register(Box::new(ListSessionsByChannelCommandHandler::new(
|
||||
store.clone(),
|
||||
)));
|
||||
// 注册 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 处理器
|
||||
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
|
||||
// 注册 switch_topic 处理器
|
||||
@ -460,7 +466,9 @@ async fn handle_inbound(
|
||||
let metadata = router.metadata_arc();
|
||||
router.register(Box::new(HelpCommandHandler::new(metadata)));
|
||||
// 注册 list_scheduler_jobs 处理器
|
||||
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
|
||||
router.register(Box::new(ListSchedulerJobsCommandHandler::new(
|
||||
store.clone(),
|
||||
)));
|
||||
// 注册 list_memories 处理器
|
||||
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
|
||||
// 注册 list_skills 处理器
|
||||
@ -524,51 +532,95 @@ async fn handle_inbound(
|
||||
*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");
|
||||
}
|
||||
}
|
||||
// 加载子智能体任务消息
|
||||
if let Some(task_session_id) = response.metadata.get("task_session_id") {
|
||||
// 提前提取 task_id,用于给历史消息打标记
|
||||
let task_id = response.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 {
|
||||
let task_id = response
|
||||
.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");
|
||||
}
|
||||
|
||||
// 发送 TaskMessagesLoaded 元数据
|
||||
let description = response.metadata.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 description = response
|
||||
.metadata
|
||||
.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 _ = sender.send(WsOutbound::TaskMessagesLoaded {
|
||||
task_id,
|
||||
description,
|
||||
subagent_type,
|
||||
status,
|
||||
summary,
|
||||
}).await;
|
||||
let _ = sender
|
||||
.send(WsOutbound::TaskMessagesLoaded {
|
||||
task_id,
|
||||
description,
|
||||
subagent_type,
|
||||
status,
|
||||
summary,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// 处理定时任务列表
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理技能列表
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 Todo 列表
|
||||
if let Some(todos_json) = response.metadata.get("todos") {
|
||||
if let Ok(todos) = serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json) {
|
||||
let scope_key = response.metadata.get("todos_scope_key").cloned().unwrap_or_default();
|
||||
if let Ok(todos) =
|
||||
serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
|
||||
{
|
||||
let scope_key = response
|
||||
.metadata
|
||||
.get("todos_scope_key")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
tracing::info!(todo_count = todos.len(), %scope_key, "list_todos command response");
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 记忆 CRUD 后自动刷新列表
|
||||
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
|
||||
.into_iter()
|
||||
.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") {
|
||||
let load_chat_channel = response.metadata.get("load_chat_channel")
|
||||
let load_chat_channel = response
|
||||
.metadata
|
||||
.get("load_chat_channel")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// session_id = "{channel}:{chat_id}" (cli channel 例外)
|
||||
let session_id = crate::storage::persistent_session_id(
|
||||
&load_chat_channel,
|
||||
load_chat_id,
|
||||
);
|
||||
if let Err(e) = send_task_messages(&store, &session_id, sender, None, None).await {
|
||||
let session_id =
|
||||
crate::storage::persistent_session_id(&load_chat_channel, load_chat_id);
|
||||
if let Err(e) =
|
||||
send_task_messages(&store, &session_id, sender, None, None).await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
channel = %load_chat_channel,
|
||||
@ -623,12 +681,22 @@ async fn handle_inbound(
|
||||
|
||||
if current_topic_id.is_none() {
|
||||
if let Some(topics_json) = response.metadata.get("topics") {
|
||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
|
||||
topics_json,
|
||||
) {
|
||||
Ok(topics) => {
|
||||
if let Some(first_topic) = topics.first() {
|
||||
let topic_id = first_topic.topic_id.clone();
|
||||
*current_topic_id = Some(topic_id.clone());
|
||||
if let Err(e) = send_topic_history(&store, current_session_id, &topic_id, sender, &state.task_repository).await {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -865,10 +933,15 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
||||
let media_type = mime_type
|
||||
.as_ref()
|
||||
.map(|m| {
|
||||
if m.starts_with("image/") { "image" }
|
||||
else if m.starts_with("audio/") { "audio" }
|
||||
else if m.starts_with("video/") { "video" }
|
||||
else { "file" }
|
||||
if m.starts_with("image/") {
|
||||
"image"
|
||||
} else if m.starts_with("audio/") {
|
||||
"audio"
|
||||
} else if m.starts_with("video/") {
|
||||
"video"
|
||||
} else {
|
||||
"file"
|
||||
}
|
||||
})
|
||||
.unwrap_or("file");
|
||||
|
||||
@ -892,7 +965,8 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
||||
"assistant" => {
|
||||
if let Some(tool_calls) = &msg.tool_calls {
|
||||
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 {
|
||||
outbound.push(WsOutbound::AssistantResponse {
|
||||
id: msg.id.clone(),
|
||||
@ -907,7 +981,11 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
||||
});
|
||||
}
|
||||
// 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 {
|
||||
outbound.push(WsOutbound::ToolCall {
|
||||
id: tool_call.id.clone(),
|
||||
@ -940,10 +1018,16 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
||||
}
|
||||
}
|
||||
"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 {
|
||||
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_name: msg.tool_name.clone().unwrap_or_default(),
|
||||
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),
|
||||
}],
|
||||
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_name: msg.tool_name.clone().unwrap_or_default(),
|
||||
content: msg.content.clone(),
|
||||
@ -983,7 +1070,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
||||
|
||||
#[cfg(test)]
|
||||
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 base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
|
||||
|
||||
@ -20,5 +20,5 @@ pub mod scheduler;
|
||||
pub mod skills;
|
||||
pub mod storage;
|
||||
pub mod text;
|
||||
pub mod topic_description;
|
||||
pub mod tools;
|
||||
pub mod topic_description;
|
||||
|
||||
@ -45,7 +45,9 @@ pub fn init_logging(timezone: Tz) {
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
let mut initialized = false;
|
||||
INIT.call_once(|| { initialized = true; });
|
||||
INIT.call_once(|| {
|
||||
initialized = true;
|
||||
});
|
||||
if !initialized {
|
||||
// Already initialized (e.g. after gateway restart), skip
|
||||
return;
|
||||
|
||||
17
src/main.rs
17
src/main.rs
@ -40,11 +40,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if std::env::args().len() <= 1 {
|
||||
cmd.print_help()?;
|
||||
println!();
|
||||
return Ok(())
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match Command::parse() {
|
||||
Command::Init { force, skip_channels } => {
|
||||
Command::Init {
|
||||
force,
|
||||
skip_channels,
|
||||
} => {
|
||||
let mut wizard = picobot::cli::InitWizard::new();
|
||||
wizard.run(force, skip_channels).await?;
|
||||
}
|
||||
@ -56,12 +59,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
picobot::client::run(&url).await?;
|
||||
}
|
||||
Command::Gateway { host, port } => {
|
||||
loop {
|
||||
let should_restart = picobot::gateway::run(host.clone(), port).await?;
|
||||
if !should_restart {
|
||||
break;
|
||||
let mut should_restart = true;
|
||||
while should_restart {
|
||||
should_restart = picobot::gateway::run(host.clone(), port).await?;
|
||||
if should_restart {
|
||||
tracing::info!("Gateway restarting...");
|
||||
}
|
||||
tracing::info!("Gateway restarting...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,14 +10,16 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use http::{HeaderName, HeaderValue};
|
||||
use rmcp::{
|
||||
model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool},
|
||||
RoleClient, ServiceExt,
|
||||
model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool},
|
||||
service::RunningService,
|
||||
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::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 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;
|
||||
}
|
||||
|
||||
@ -208,7 +214,10 @@ impl McpClientManager {
|
||||
"Failed to connect to MCP server after all retries"
|
||||
);
|
||||
// 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;
|
||||
} else {
|
||||
// Clear any previous error on successful connection
|
||||
@ -238,18 +247,23 @@ impl McpClientManager {
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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 client = match transport {
|
||||
McpTransportConfig::Stdio { command, args, env, cwd } => {
|
||||
self.connect_stdio(key, &command, &args, &env, &cwd).await?
|
||||
}
|
||||
McpTransportConfig::Http { url, headers } => {
|
||||
self.connect_http(&url, &headers).await?
|
||||
}
|
||||
McpTransportConfig::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
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+)
|
||||
@ -299,7 +313,10 @@ impl McpClientManager {
|
||||
if resolved_command.is_none() {
|
||||
let path = std::path::Path::new(command);
|
||||
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() {
|
||||
// Bare name not found on Windows
|
||||
let path_env = std::env::var("PATH").unwrap_or_default();
|
||||
@ -308,7 +325,8 @@ impl McpClientManager {
|
||||
Current PATH: {}. \
|
||||
Suggestion: use the full absolute path to the executable, \
|
||||
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
|
||||
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)
|
||||
}
|
||||
@ -446,25 +465,21 @@ impl McpClientManager {
|
||||
.iter()
|
||||
.filter_map(|(key, value)| {
|
||||
// Try to parse header name and value
|
||||
HeaderName::try_from(key.clone())
|
||||
.ok()
|
||||
.and_then(|name| {
|
||||
HeaderValue::try_from(value.clone())
|
||||
.ok()
|
||||
.map(|val| (name, val))
|
||||
})
|
||||
HeaderName::try_from(key.clone()).ok().and_then(|name| {
|
||||
HeaderValue::try_from(value.clone())
|
||||
.ok()
|
||||
.map(|val| (name, val))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create transport config with custom headers
|
||||
let config = StreamableHttpClientTransportConfig::with_uri(url)
|
||||
.custom_headers(custom_headers);
|
||||
let config =
|
||||
StreamableHttpClientTransportConfig::with_uri(url).custom_headers(custom_headers);
|
||||
|
||||
// Create transport using reqwest client (default)
|
||||
let transport = StreamableHttpClientTransport::with_client(
|
||||
reqwest::Client::default(),
|
||||
config,
|
||||
);
|
||||
let transport =
|
||||
StreamableHttpClientTransport::with_client(reqwest::Client::default(), config);
|
||||
|
||||
// Connect
|
||||
let client = ().serve(transport).await?;
|
||||
@ -497,7 +512,9 @@ impl McpClientManager {
|
||||
info_map
|
||||
.values()
|
||||
.flat_map(|info| {
|
||||
info.tools.iter().map(|tool| (info.key.clone(), tool.clone()))
|
||||
info.tools
|
||||
.iter()
|
||||
.map(|tool| (info.key.clone(), tool.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@ -561,7 +578,9 @@ impl McpClientManager {
|
||||
/// gateway restart where old MCP processes may still be running when
|
||||
/// new ones start.
|
||||
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)
|
||||
self.disconnect_all().await?;
|
||||
@ -578,7 +597,8 @@ impl McpClientManager {
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||
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(())
|
||||
@ -766,7 +786,10 @@ impl McpInitializer {
|
||||
///
|
||||
/// This should be called after the gateway is ready to accept 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() {
|
||||
// Wait for connections to complete first
|
||||
self.wait_for_connections().await?;
|
||||
@ -789,9 +812,15 @@ mod tests {
|
||||
// On all platforms, this should return None (either because it's not found,
|
||||
// or because non-Windows always returns None)
|
||||
#[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))]
|
||||
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]
|
||||
@ -806,7 +835,10 @@ mod tests {
|
||||
#[cfg(windows)]
|
||||
assert!(result.is_some(), "Expected to find {} on Windows", cmd);
|
||||
#[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]
|
||||
@ -820,7 +852,10 @@ mod tests {
|
||||
fn test_resolve_command_path_finds_known_windows_binary() {
|
||||
// cmd.exe should always be in C:\Windows\System32 which is in PATH
|
||||
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 {
|
||||
assert!(
|
||||
p.to_string_lossy().to_lowercase().ends_with("cmd.exe"),
|
||||
@ -829,4 +864,4 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,7 +203,11 @@ mod tests {
|
||||
let config = McpServerConfig::stdio(
|
||||
"filesystem",
|
||||
"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()));
|
||||
@ -267,11 +271,14 @@ mod tests {
|
||||
match transport {
|
||||
McpTransportConfig::Stdio { command, args, .. } => {
|
||||
assert_eq!(command, "npx");
|
||||
assert_eq!(args, vec![
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/home/user"
|
||||
]);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/home/user"
|
||||
]
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected stdio transport"),
|
||||
}
|
||||
@ -279,12 +286,18 @@ mod tests {
|
||||
// Check WebSearch server (streamableHttp)
|
||||
let websearch = config.mcp_servers.get("WebSearch").unwrap();
|
||||
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);
|
||||
let transport = websearch.transport().unwrap();
|
||||
match transport {
|
||||
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!(
|
||||
headers.get("Authorization"),
|
||||
Some(&"Bearer ${DASHSCOPE_API_KEY}".to_string())
|
||||
@ -385,17 +398,31 @@ mod tests {
|
||||
#[test]
|
||||
fn test_http_type_alias() {
|
||||
// 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 config_http: McpConfig = serde_json::from_str(json_http).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_streamable = config_streamable.mcp_servers.get("test").unwrap().transport().unwrap();
|
||||
let transport_http = config_http
|
||||
.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_streamable, McpTransportConfig::Http { .. }));
|
||||
assert!(matches!(
|
||||
transport_streamable,
|
||||
McpTransportConfig::Http { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -420,4 +447,4 @@ mod tests {
|
||||
let server = config.mcp_servers.get("test").unwrap();
|
||||
assert!(server.cwd.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,10 +11,12 @@
|
||||
//!
|
||||
//! MCP is completely optional and disabled by default.
|
||||
|
||||
pub mod config;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod tool_adapter;
|
||||
|
||||
pub use client::{
|
||||
McpClient, McpClientManager, McpInitializer, McpServerInfo, McpServerStatus, McpStatusResponse,
|
||||
};
|
||||
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};
|
||||
|
||||
@ -25,11 +25,7 @@ pub struct McpToolWrapper {
|
||||
|
||||
impl McpToolWrapper {
|
||||
/// Create a new tool wrapper
|
||||
pub fn new(
|
||||
manager: Arc<McpClientManager>,
|
||||
server_key: String,
|
||||
tool_info: Tool,
|
||||
) -> Self {
|
||||
pub fn new(manager: Arc<McpClientManager>, server_key: String, tool_info: Tool) -> Self {
|
||||
let tool_name = tool_info.name.clone().into_owned();
|
||||
let full_name = format!("mcp_{}_{}", server_key, tool_name);
|
||||
Self {
|
||||
@ -128,11 +124,7 @@ pub async fn register_mcp_tools(
|
||||
let all_tools = manager.all_tools().await;
|
||||
|
||||
for (server_key, tool_info) in all_tools {
|
||||
let wrapper = McpToolWrapper::new(
|
||||
manager.clone(),
|
||||
server_key.clone(),
|
||||
tool_info,
|
||||
);
|
||||
let wrapper = McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info);
|
||||
|
||||
tracing::info!(
|
||||
name = %wrapper.name(),
|
||||
@ -153,10 +145,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extract_text_content_from_text() {
|
||||
let result = CallToolResult::success(vec![
|
||||
Content::text("Hello"),
|
||||
Content::text("World"),
|
||||
]);
|
||||
let result = CallToolResult::success(vec![Content::text("Hello"), Content::text("World")]);
|
||||
|
||||
let text = extract_text_content(&result);
|
||||
assert_eq!(text, "Hello\nWorld");
|
||||
@ -175,10 +164,11 @@ mod tests {
|
||||
fn test_mcp_tool_wrapper_name() {
|
||||
let manager = Arc::new(McpClientManager::new());
|
||||
// Create a minimal tool info using rmcp's Tool constructor
|
||||
let schema: serde_json::Map<String, serde_json::Value> = serde_json::json!({"type": "object"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let schema: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::json!({"type": "object"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let tool_info = Tool::new("echo", "Echo tool", schema);
|
||||
|
||||
let wrapper = McpToolWrapper::new(manager, "filesystem".to_string(), tool_info);
|
||||
@ -186,4 +176,4 @@ mod tests {
|
||||
assert_eq!(wrapper.original_name(), "echo");
|
||||
assert_eq!(wrapper.server_key(), "filesystem");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,7 +300,8 @@ mod tests {
|
||||
fn test_truncate_args_utf8_boundary() {
|
||||
// Test that truncation respects UTF-8 character boundaries
|
||||
// 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);
|
||||
assert!(truncated.ends_with("...truncated"));
|
||||
// Verify the truncated string is valid UTF-8 (no panic occurred)
|
||||
|
||||
@ -151,11 +151,7 @@ pub fn is_process_waiting_on_stdin(pid: u32) -> Option<bool> {
|
||||
if wchan.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
wchan.contains("tty_read")
|
||||
|| wchan.contains("n_tty_read")
|
||||
|| wchan == "pipe_wait",
|
||||
)
|
||||
Some(wchan.contains("tty_read") || wchan.contains("n_tty_read") || wchan == "pipe_wait")
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@ -525,4 +521,4 @@ mod tests {
|
||||
assert_eq!(xml_escape("a & b"), "a & b");
|
||||
assert_eq!(xml_escape("<tag>"), "<tag>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,9 +240,7 @@ pub enum WsOutbound {
|
||||
channel_name: Option<String>,
|
||||
},
|
||||
#[serde(rename = "channel_list")]
|
||||
ChannelList {
|
||||
channels: Vec<Channel>,
|
||||
},
|
||||
ChannelList { channels: Vec<Channel> },
|
||||
#[serde(rename = "topic_list")]
|
||||
TopicList {
|
||||
topics: Vec<TopicSummary>,
|
||||
@ -262,7 +260,10 @@ pub enum WsOutbound {
|
||||
message_count: i64,
|
||||
},
|
||||
#[serde(rename = "session_saved")]
|
||||
SessionSaved { session_id: String, filepath: String },
|
||||
SessionSaved {
|
||||
session_id: String,
|
||||
filepath: String,
|
||||
},
|
||||
#[serde(rename = "task_messages_loaded")]
|
||||
TaskMessagesLoaded {
|
||||
task_id: String,
|
||||
@ -273,17 +274,11 @@ pub enum WsOutbound {
|
||||
summary: Option<String>,
|
||||
},
|
||||
#[serde(rename = "scheduler_job_list")]
|
||||
SchedulerJobList {
|
||||
jobs: Vec<SchedulerJobSummary>,
|
||||
},
|
||||
SchedulerJobList { jobs: Vec<SchedulerJobSummary> },
|
||||
#[serde(rename = "memory_list")]
|
||||
MemoryList {
|
||||
memories: Vec<MemorySummary>,
|
||||
},
|
||||
MemoryList { memories: Vec<MemorySummary> },
|
||||
#[serde(rename = "skill_list")]
|
||||
SkillList {
|
||||
skills: Vec<SkillSummary>,
|
||||
},
|
||||
SkillList { skills: Vec<SkillSummary> },
|
||||
#[serde(rename = "execution_cancelled")]
|
||||
ExecutionCancelled { message: String },
|
||||
#[serde(rename = "stream_delta")]
|
||||
|
||||
@ -15,7 +15,8 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
||||
"assistant" => {
|
||||
if let Some(tool_calls) = &message.tool_calls {
|
||||
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 {
|
||||
outbound.push(WsOutbound::AssistantResponse {
|
||||
id: message.id.clone(),
|
||||
@ -31,7 +32,11 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
{
|
||||
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_name: message.tool_name.clone().unwrap_or_default(),
|
||||
content: message.content.clone(),
|
||||
@ -77,7 +85,10 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
||||
timestamp: None,
|
||||
}],
|
||||
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_name: message.tool_name.clone().unwrap_or_default(),
|
||||
content: message.content.clone(),
|
||||
@ -107,7 +118,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
||||
})
|
||||
.collect();
|
||||
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(),
|
||||
role: message.role.clone(),
|
||||
attachments,
|
||||
@ -176,8 +190,16 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
||||
}],
|
||||
OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted {
|
||||
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
|
||||
description: message.metadata.get("task_description").cloned().unwrap_or_default(),
|
||||
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
|
||||
description: message
|
||||
.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(),
|
||||
parent_task_id: message.metadata.get("parent_task_id").cloned(),
|
||||
tool_call_id: message.metadata.get("tool_call_id").cloned(),
|
||||
|
||||
@ -41,7 +41,9 @@ fn convert_content_blocks(
|
||||
) -> Vec<serde_json::Value> {
|
||||
// 检查是否有图片且模型不支持
|
||||
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 {
|
||||
let image_count = blocks
|
||||
@ -79,10 +81,8 @@ fn convert_content_blocks(
|
||||
|
||||
// 添加通知文本块
|
||||
if !notices.is_empty() {
|
||||
let notice_text = format!(
|
||||
"[系统提示] 以下图片未能成功入模:\n{}",
|
||||
notices.join("\n")
|
||||
);
|
||||
let notice_text =
|
||||
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
|
||||
converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text }));
|
||||
}
|
||||
|
||||
@ -182,9 +182,7 @@ impl AnthropicProvider {
|
||||
self.model_extra
|
||||
.get("supported_content_types")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|types| {
|
||||
types.iter().any(|t| t.as_str() == Some(content_type))
|
||||
})
|
||||
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,11 @@ use super::traits::{StreamCallback, StreamDelta, Usage};
|
||||
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall};
|
||||
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)]
|
||||
@ -49,8 +53,17 @@ impl StreamingAccumulator {
|
||||
}
|
||||
|
||||
/// 添加工具调用增量
|
||||
fn add_tool_call(&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);
|
||||
fn add_tool_call(
|
||||
&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 覆盖之前的值
|
||||
if let Some(id) = id {
|
||||
@ -78,7 +91,8 @@ impl StreamingAccumulator {
|
||||
|
||||
/// 构建最终的 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()
|
||||
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
|
||||
.map(|(_, call)| {
|
||||
@ -149,10 +163,13 @@ fn convert_content_blocks(
|
||||
) -> Value {
|
||||
// 检查是否有图片且模型不支持
|
||||
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 {
|
||||
let image_count = blocks.iter()
|
||||
let image_count = blocks
|
||||
.iter()
|
||||
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
|
||||
.count();
|
||||
|
||||
@ -186,10 +203,8 @@ fn convert_content_blocks(
|
||||
|
||||
// 添加通知文本块
|
||||
if !notices.is_empty() {
|
||||
let notice_text = format!(
|
||||
"[系统提示] 以下图片未能成功入模:\n{}",
|
||||
notices.join("\n")
|
||||
);
|
||||
let notice_text =
|
||||
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
|
||||
converted_blocks.push(json!({ "type": "text", "text": notice_text }));
|
||||
}
|
||||
|
||||
@ -303,9 +318,7 @@ impl OpenAIProvider {
|
||||
self.model_extra
|
||||
.get("supported_content_types")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|types| {
|
||||
types.iter().any(|t| t.as_str() == Some(content_type))
|
||||
})
|
||||
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
@ -339,7 +352,9 @@ impl OpenAIProvider {
|
||||
Value::String(raw)
|
||||
} else {
|
||||
// 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(
|
||||
@ -414,7 +429,7 @@ impl OpenAIProvider {
|
||||
// 读取 SSE 流
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
let mut raw_body = String::new(); // 完整原始响应,用于非 SSE JSON 回退
|
||||
let mut raw_body = String::new(); // 完整原始响应,用于非 SSE JSON 回退
|
||||
let mut done_received = false;
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
@ -435,7 +450,8 @@ impl OpenAIProvider {
|
||||
}
|
||||
|
||||
// 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:"));
|
||||
|
||||
if let Some(data) = data_opt {
|
||||
@ -459,7 +475,9 @@ impl OpenAIProvider {
|
||||
// 尝试从 delta 提取(标准 OpenAI 流式格式)
|
||||
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);
|
||||
if let Some(cb) = &stream_callback {
|
||||
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);
|
||||
if let Some(cb) = &stream_callback {
|
||||
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 {
|
||||
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 name = tool_call.get("function")
|
||||
let id =
|
||||
tool_call.get("id").and_then(|v| v.as_str());
|
||||
let name = tool_call
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.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(|a| a.as_str());
|
||||
|
||||
accumulator.add_tool_call(index, id, name, arguments);
|
||||
accumulator
|
||||
.add_tool_call(index, id, name, arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 尝试从 message 提取(某些非标准 API 格式)
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -533,7 +568,8 @@ impl OpenAIProvider {
|
||||
}
|
||||
|
||||
// 同样支持 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:"));
|
||||
|
||||
if let Some(data) = data_opt {
|
||||
@ -550,7 +586,8 @@ impl OpenAIProvider {
|
||||
for choice in choices {
|
||||
// 尝试从 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);
|
||||
if let Some(cb) = &stream_callback {
|
||||
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);
|
||||
if let Some(cb) = &stream_callback {
|
||||
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 {
|
||||
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 name = tool_call.get("function")
|
||||
let name = tool_call
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.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(|a| a.as_str());
|
||||
accumulator.add_tool_call(index, id, name, arguments);
|
||||
@ -584,10 +631,14 @@ impl OpenAIProvider {
|
||||
}
|
||||
// 尝试从 message 提取(某些非标准 API 格式)
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -603,7 +654,8 @@ impl OpenAIProvider {
|
||||
// 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体
|
||||
if response.content.is_empty() && response.tool_calls.is_empty() {
|
||||
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
|
||||
let fallback_content = openai_resp.choices
|
||||
let fallback_content = openai_resp
|
||||
.choices
|
||||
.first()
|
||||
.and_then(|c| c.message.content.as_deref())
|
||||
.unwrap_or("")
|
||||
@ -614,22 +666,29 @@ impl OpenAIProvider {
|
||||
"Streaming accumulator empty, falling back to non-SSE JSON parsing"
|
||||
);
|
||||
response.content = fallback_content;
|
||||
response.reasoning_content = openai_resp.choices
|
||||
response.reasoning_content = openai_resp
|
||||
.choices
|
||||
.first()
|
||||
.and_then(|c| c.message.reasoning_content.clone());
|
||||
response.tool_calls = openai_resp.choices
|
||||
response.tool_calls = openai_resp
|
||||
.choices
|
||||
.first()
|
||||
.map(|c| {
|
||||
c.message.tool_calls.iter().map(|tc| ToolCall {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
arguments: match &tc.function.arguments {
|
||||
OAIFunctionArguments::Json(args) => args.clone(),
|
||||
OAIFunctionArguments::String(args) => {
|
||||
serde_json::from_str(args).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
},
|
||||
}).collect()
|
||||
c.message
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| ToolCall {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
arguments: match &tc.function.arguments {
|
||||
OAIFunctionArguments::Json(args) => args.clone(),
|
||||
OAIFunctionArguments::String(args) => {
|
||||
serde_json::from_str(args)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
@ -656,9 +715,11 @@ impl OpenAIProvider {
|
||||
// result that precedes its parent assistant (e.g. after compaction
|
||||
// boundary splits), leading to API 400 errors:
|
||||
// "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 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() {
|
||||
if m.role == "tool" {
|
||||
@ -670,8 +731,9 @@ impl OpenAIProvider {
|
||||
if m.role == "assistant" {
|
||||
if let Some(ref calls) = m.tool_calls {
|
||||
if !calls.is_empty() {
|
||||
let all_resolved =
|
||||
calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
||||
let all_resolved = calls
|
||||
.iter()
|
||||
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
||||
if all_resolved {
|
||||
for tc in calls {
|
||||
with_parent.insert(tc.id.as_str());
|
||||
@ -695,7 +757,8 @@ impl OpenAIProvider {
|
||||
// ^ reverse scan sees tool(A) after assistant → "resolved"
|
||||
// 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;
|
||||
|
||||
for (i, m) in request.messages.iter().enumerate() {
|
||||
@ -892,30 +955,38 @@ impl OpenAIProvider {
|
||||
/// avoid flooding logs on every request — see callers in `chat` and
|
||||
/// `chat_streaming_internal`.
|
||||
fn format_message_sequence(body: &Value) -> Vec<String> {
|
||||
body["messages"].as_array()
|
||||
.map(|msgs| msgs.iter().enumerate().map(|(i, m)| {
|
||||
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
|
||||
match role {
|
||||
"assistant" => {
|
||||
let tc_count = m.get("tool_calls")
|
||||
.and_then(|t| t.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
if tc_count > 0 {
|
||||
format!("[{}] assistant(tool_calls={})", i, tc_count)
|
||||
} else {
|
||||
format!("[{}] assistant", i)
|
||||
body["messages"]
|
||||
.as_array()
|
||||
.map(|msgs| {
|
||||
msgs.iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
|
||||
match role {
|
||||
"assistant" => {
|
||||
let tc_count = m
|
||||
.get("tool_calls")
|
||||
.and_then(|t| t.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
if tc_count > 0 {
|
||||
format!("[{}] assistant(tool_calls={})", i, tc_count)
|
||||
} else {
|
||||
format!("[{}] assistant", i)
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
let tcid = m
|
||||
.get("tool_call_id")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("??");
|
||||
format!("[{}] tool(id={})", i, tcid)
|
||||
}
|
||||
_ => format!("[{}] {}", i, role),
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
let tcid = m.get("tool_call_id")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("??");
|
||||
format!("[{}] tool(id={})", i, tcid)
|
||||
}
|
||||
_ => format!("[{}] {}", i, role),
|
||||
}
|
||||
}).collect())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@ -1141,7 +1212,10 @@ impl LLMProvider for OpenAIProvider {
|
||||
callback: StreamCallback,
|
||||
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
@ -1471,7 +1545,12 @@ mod tests {
|
||||
let mut accumulator = StreamingAccumulator::new();
|
||||
|
||||
// 第一个 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:只有参数增量
|
||||
accumulator.add_tool_call(0, None, None, Some("list"));
|
||||
// 第三个 chunk:参数继续
|
||||
@ -1487,7 +1566,10 @@ mod tests {
|
||||
assert_eq!(response.tool_calls.len(), 1);
|
||||
assert_eq!(response.tool_calls[0].id, "call_abc123");
|
||||
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]
|
||||
@ -1495,7 +1577,12 @@ mod tests {
|
||||
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 出现)
|
||||
accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}"));
|
||||
|
||||
@ -1600,7 +1687,10 @@ mod tests {
|
||||
"supported_content_types".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();
|
||||
|
||||
// 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())
|
||||
.expect("tool_calls should be preserved when immediately followed");
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use crate::config::LLMProviderConfig;
|
||||
use crate::domain::messages::{ContentBlock, ToolCall};
|
||||
use crate::domain::tools::Tool;
|
||||
use crate::config::LLMProviderConfig;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@ -59,7 +59,9 @@ pub trait AgentTaskExecutor: Send + Sync {
|
||||
pub trait MaintenanceExecutor: Send + Sync {
|
||||
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 {
|
||||
@ -452,11 +454,15 @@ fn scheduler_job_definition_matches(
|
||||
existing: &SchedulerJobRecord,
|
||||
) -> bool {
|
||||
let input_schedule = serde_json::from_value::<SchedulerSchedule>(input.schedule.clone()).ok();
|
||||
let existing_schedule =
|
||||
deserialize_schedule(&existing.schedule, existing.interval_secs, existing.startup_delay_secs)
|
||||
.ok();
|
||||
let existing_schedule = deserialize_schedule(
|
||||
&existing.schedule,
|
||||
existing.interval_secs,
|
||||
existing.startup_delay_secs,
|
||||
)
|
||||
.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) {
|
||||
(Some(input_target), Some(existing_target)) => {
|
||||
input_target.channel == existing_target.channel
|
||||
@ -813,7 +819,10 @@ fn convert_weekday_field(expression: &str) -> String {
|
||||
let weekday_field = parts[5];
|
||||
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 converted_items: Vec<String> = items.iter().map(|item| {
|
||||
convert_weekday_item(item.trim())
|
||||
}).collect();
|
||||
let converted_items: Vec<String> = items
|
||||
.iter()
|
||||
.map(|item| convert_weekday_item(item.trim()))
|
||||
.collect();
|
||||
|
||||
converted_items.join(",")
|
||||
}
|
||||
@ -865,14 +875,14 @@ fn convert_weekday_range_or_value(item: &str) -> String {
|
||||
/// 转换单个星期数字
|
||||
fn convert_single_weekday(day: &str) -> String {
|
||||
match day {
|
||||
"0" | "7" => "1".to_string(), // 周日 -> 1
|
||||
"1" => "2".to_string(), // 周一 -> 2
|
||||
"2" => "3".to_string(), // 周二 -> 3
|
||||
"3" => "4".to_string(), // 周三 -> 4
|
||||
"4" => "5".to_string(), // 周四 -> 5
|
||||
"5" => "6".to_string(), // 周五 -> 6
|
||||
"6" => "7".to_string(), // 周六 -> 7
|
||||
_ => day.to_string(), // 其他(如字母)保持不变
|
||||
"0" | "7" => "1".to_string(), // 周日 -> 1
|
||||
"1" => "2".to_string(), // 周一 -> 2
|
||||
"2" => "3".to_string(), // 周二 -> 3
|
||||
"3" => "4".to_string(), // 周三 -> 4
|
||||
"4" => "5".to_string(), // 周四 -> 5
|
||||
"5" => "6".to_string(), // 周五 -> 6
|
||||
"6" => "7".to_string(), // 周六 -> 7
|
||||
_ => day.to_string(), // 其他(如字母)保持不变
|
||||
}
|
||||
}
|
||||
|
||||
@ -929,7 +939,9 @@ async fn execute_internal_event(
|
||||
Ok(())
|
||||
}
|
||||
"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 {
|
||||
tracing::info!(
|
||||
job_id = %job.id,
|
||||
@ -1284,10 +1296,10 @@ impl TryFrom<serde_json::Value> for SchedulerJobTarget {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{Datelike, Timelike};
|
||||
use crate::bus::MessageBus;
|
||||
use crate::config::BUILTIN_MEMORY_MAINTENANCE_JOB_ID;
|
||||
use crate::storage::{SchedulerJobUpsert, SessionStore};
|
||||
use chrono::{Datelike, Timelike};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestAgentTaskExecutor;
|
||||
@ -1325,7 +1337,9 @@ mod tests {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@ -1592,17 +1606,19 @@ mod tests {
|
||||
|
||||
let probe_runtime = RuntimeJob::from_config(
|
||||
&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,
|
||||
chrono_tz::Asia::Shanghai,
|
||||
)
|
||||
.unwrap();
|
||||
let probe_existing = store
|
||||
.get_scheduler_job("agent.heartbeat")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let probe_existing = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
|
||||
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 scheduler = Scheduler::new(
|
||||
@ -1622,10 +1638,7 @@ mod tests {
|
||||
|
||||
scheduler.sync_config_jobs().unwrap();
|
||||
|
||||
let saved = store
|
||||
.get_scheduler_job("agent.heartbeat")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
|
||||
|
||||
assert_eq!(saved.next_fire_at, Some(persisted_next_fire_at));
|
||||
assert_eq!(saved.run_count, 3);
|
||||
@ -1723,7 +1736,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn debug_cron_weekday_definitions() {
|
||||
// 重大发现:cron crate 的星期定义是反常规的!
|
||||
@ -1742,9 +1754,16 @@ mod tests {
|
||||
];
|
||||
|
||||
// 从周六(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);
|
||||
println!("\n=== 从周六 {} ({:?}) 开始测试 ===", shanghai_saturday, shanghai_saturday.weekday());
|
||||
println!(
|
||||
"\n=== 从周六 {} ({:?}) 开始测试 ===",
|
||||
shanghai_saturday,
|
||||
shanghai_saturday.weekday()
|
||||
);
|
||||
|
||||
for (expr, desc) in &test_cases {
|
||||
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 sat_next = schedule_workday.after(&shanghai_saturday).next().unwrap();
|
||||
println!("周六 -> 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday());
|
||||
assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周六跳到周一");
|
||||
println!(
|
||||
"周六 -> 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 sun_next = schedule_workday.after(&shanghai_sunday).next().unwrap();
|
||||
println!("周日 -> 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday());
|
||||
assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周日跳到周一");
|
||||
println!(
|
||||
"周日 -> 1-5 下次执行: {} (星期: {:?})",
|
||||
sun_next,
|
||||
sun_next.weekday()
|
||||
);
|
||||
assert_eq!(
|
||||
sun_next.weekday(),
|
||||
chrono::Weekday::Mon,
|
||||
"1-5 应该从周日跳到周一"
|
||||
);
|
||||
|
||||
// 从周一早上7点开始
|
||||
let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap();
|
||||
println!("周一早上7点 -> 1-5 下次执行: {} (星期: {:?})",
|
||||
let shanghai_monday = chrono_tz::Asia::Shanghai
|
||||
.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().weekday());
|
||||
schedule_workday
|
||||
.after(&shanghai_monday)
|
||||
.next()
|
||||
.unwrap()
|
||||
.weekday()
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试标准 cron 星期转换功能
|
||||
@ -1784,62 +1831,103 @@ mod tests {
|
||||
#[test]
|
||||
fn standard_cron_weekday_conversion() {
|
||||
// 测试:标准 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);
|
||||
|
||||
// 现在使用标准 cron:1-5 表示周一到周五
|
||||
let schedule_std = parse_scheduler_cron("0 9 * * 1-5").unwrap();
|
||||
|
||||
let sat_next = schedule_std.after(&shanghai_saturday).next().unwrap();
|
||||
println!("周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday());
|
||||
assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周六跳到周一");
|
||||
println!(
|
||||
"周六 -> 标准 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 sun_next = schedule_std.after(&shanghai_sunday).next().unwrap();
|
||||
println!("周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday());
|
||||
assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周日跳到周一");
|
||||
println!(
|
||||
"周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
|
||||
sun_next,
|
||||
sun_next.weekday()
|
||||
);
|
||||
assert_eq!(
|
||||
sun_next.weekday(),
|
||||
chrono::Weekday::Mon,
|
||||
"标准 cron 1-5 应该从周日跳到周一"
|
||||
);
|
||||
|
||||
// 从周一开始(上海时间周一早上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();
|
||||
println!("周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", mon_next, mon_next.weekday());
|
||||
assert_eq!(mon_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该当天执行");
|
||||
println!(
|
||||
"周一早上 -> 标准 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点");
|
||||
|
||||
// 从周五开始(应该下周周一)
|
||||
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 fri_next = schedule_std.after(&shanghai_friday).next().unwrap();
|
||||
println!("周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", fri_next, fri_next.weekday());
|
||||
assert_eq!(fri_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周五跳到下周一");
|
||||
println!(
|
||||
"周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
|
||||
fri_next,
|
||||
fri_next.weekday()
|
||||
);
|
||||
assert_eq!(
|
||||
fri_next.weekday(),
|
||||
chrono::Weekday::Mon,
|
||||
"标准 cron 1-5 应该从周五跳到下周一"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试转换辅助函数
|
||||
#[test]
|
||||
fn test_weekday_conversion_helper() {
|
||||
// 测试单个值
|
||||
assert_eq!(convert_single_weekday("0"), "1"); // 周日
|
||||
assert_eq!(convert_single_weekday("1"), "2"); // 周一
|
||||
assert_eq!(convert_single_weekday("5"), "6"); // 周五
|
||||
assert_eq!(convert_single_weekday("6"), "7"); // 周六
|
||||
assert_eq!(convert_single_weekday("7"), "1"); // 周日(标准 cron 兼容写法)
|
||||
assert_eq!(convert_single_weekday("0"), "1"); // 周日
|
||||
assert_eq!(convert_single_weekday("1"), "2"); // 周一
|
||||
assert_eq!(convert_single_weekday("5"), "6"); // 周五
|
||||
assert_eq!(convert_single_weekday("6"), "7"); // 周六
|
||||
assert_eq!(convert_single_weekday("7"), "1"); // 周日(标准 cron 兼容写法)
|
||||
|
||||
// 测试范围
|
||||
assert_eq!(convert_weekday_range_or_value("1-5"), "2-6"); // 周一到周五
|
||||
assert_eq!(convert_weekday_range_or_value("0-6"), "1-7"); // 周日到周六
|
||||
assert_eq!(convert_weekday_range_or_value("0-7"), "1-1"); // 周日(循环)
|
||||
assert_eq!(convert_weekday_range_or_value("1-5"), "2-6"); // 周一到周五
|
||||
assert_eq!(convert_weekday_range_or_value("0-6"), "1-7"); // 周日到周六
|
||||
assert_eq!(convert_weekday_range_or_value("0-7"), "1-1"); // 周日(循环)
|
||||
|
||||
// 测试列表
|
||||
assert_eq!(convert_cron_weekday("1,3,5"), "2,4,6"); // 周一、三、五
|
||||
assert_eq!(convert_cron_weekday("0,6"), "1,7"); // 周日和周六
|
||||
assert_eq!(convert_cron_weekday("1,3,5"), "2,4,6"); // 周一、三、五
|
||||
assert_eq!(convert_cron_weekday("0,6"), "1,7"); // 周日和周六
|
||||
|
||||
// 测试步长
|
||||
assert_eq!(convert_weekday_item("*/2"), "*/2"); // 步长保持不变
|
||||
assert_eq!(convert_weekday_item("*/2"), "*/2"); // 步长保持不变
|
||||
|
||||
// 测试混合
|
||||
assert_eq!(convert_cron_weekday("1-5,7"), "2-6,1"); // 周一到周五 + 周日
|
||||
assert_eq!(convert_cron_weekday("1-5,7"), "2-6,1"); // 周一到周五 + 周日
|
||||
|
||||
// 测试特殊字符
|
||||
assert_eq!(convert_cron_weekday("*"), "*");
|
||||
|
||||
@ -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_json::json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@ -11,7 +13,9 @@ static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
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;
|
||||
@ -209,16 +213,23 @@ impl SkillRuntime {
|
||||
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
|
||||
let disable_state = load_skill_disable_state(&cwd);
|
||||
|
||||
catalog.skills.iter().map(|skill| {
|
||||
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
|
||||
SkillWithStatus {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
source: skill.source.as_str().to_string(),
|
||||
path: skill.path.display().to_string(),
|
||||
disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
||||
}
|
||||
}).collect()
|
||||
catalog
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| {
|
||||
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
|
||||
SkillWithStatus {
|
||||
name: skill.name.clone(),
|
||||
description: skill.description.clone(),
|
||||
source: skill.source.as_str().to_string(),
|
||||
path: skill.path.display().to_string(),
|
||||
disabled_in_scopes: disabled_scopes
|
||||
.iter()
|
||||
.map(|s| s.as_str().to_string())
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
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> {
|
||||
validate_skill_name(name)?;
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|err| format!("failed to get current dir: {}", err))?;
|
||||
let cwd =
|
||||
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
|
||||
Ok(SkillCatalog::discover_without_state(&self.config, &cwd)
|
||||
.find_skill(name)
|
||||
.is_some())
|
||||
@ -358,8 +369,8 @@ impl SkillRuntime {
|
||||
let _ = self.reload()?;
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|err| format!("failed to get current dir: {}", err))?;
|
||||
let cwd =
|
||||
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
|
||||
let effective_state = load_skill_disable_state(&cwd);
|
||||
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> {
|
||||
match scope {
|
||||
SkillScope::User => user_skill_state_path()
|
||||
.ok_or_else(|| "failed to resolve home directory".to_string()),
|
||||
SkillScope::User => {
|
||||
user_skill_state_path().ok_or_else(|| "failed to resolve home directory".to_string())
|
||||
}
|
||||
SkillScope::Project => {
|
||||
let cwd = std::env::current_dir()
|
||||
.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() {
|
||||
Some(sid) => {
|
||||
let policy = self
|
||||
.experts
|
||||
.selected_expert_for(sid)
|
||||
.map(|e| e.capability);
|
||||
let policy = self.experts.selected_expert_for(sid).map(|e| e.capability);
|
||||
match policy {
|
||||
Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered(
|
||||
p.allowed_skills.as_deref(),
|
||||
@ -1058,7 +1067,11 @@ mod tests {
|
||||
let skill_dir = dir.path().join("demo");
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
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();
|
||||
assert_eq!(skill.name, "demo");
|
||||
@ -1128,7 +1141,10 @@ mod tests {
|
||||
|
||||
// 验证 location 包含正确的 file:// URI 格式
|
||||
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>"));
|
||||
}
|
||||
|
||||
@ -1388,13 +1404,17 @@ mod tests {
|
||||
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_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]);
|
||||
assert!(!disabled.available);
|
||||
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.disabled_in_scopes.is_empty());
|
||||
assert!(enabled.available);
|
||||
@ -1427,16 +1447,22 @@ mod tests {
|
||||
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!(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_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]);
|
||||
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.disabled_in_scopes.is_empty());
|
||||
assert!(runtime.get_skill("demo").is_some());
|
||||
@ -1506,7 +1532,9 @@ mod tests {
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@ -1565,7 +1593,9 @@ mod tests {
|
||||
);
|
||||
|
||||
// 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();
|
||||
assert_eq!(skills_after.len(), 1);
|
||||
assert!(skills_after[0].disabled_in_scopes.is_empty());
|
||||
|
||||
@ -24,10 +24,10 @@ pub use ports::{
|
||||
SkillEventRepository, TodoRepository,
|
||||
};
|
||||
pub use records::{
|
||||
allowed_namespace_names, get_namespace_description, is_valid_namespace,
|
||||
ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord,
|
||||
SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord,
|
||||
TodoRecord, TopicRecord,
|
||||
TodoRecord, TopicRecord, allowed_namespace_names, get_namespace_description,
|
||||
is_valid_namespace,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@ -228,14 +228,11 @@ impl SessionStore {
|
||||
|
||||
drop(conn);
|
||||
|
||||
let manager = SqliteConnectionManager::file(db_uri)
|
||||
.with_init(|c| {
|
||||
c.busy_timeout(std::time::Duration::from_secs(30))?;
|
||||
Ok(())
|
||||
});
|
||||
let pool = Pool::builder()
|
||||
.max_size(8)
|
||||
.build(manager)?;
|
||||
let manager = SqliteConnectionManager::file(db_uri).with_init(|c| {
|
||||
c.busy_timeout(std::time::Duration::from_secs(30))?;
|
||||
Ok(())
|
||||
});
|
||||
let pool = Pool::builder().max_size(8).build(manager)?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
@ -245,8 +242,7 @@ impl SessionStore {
|
||||
// Use a temp file so the database survives across pool connections.
|
||||
// Temp dir is cleaned by the OS eventually; tests that need cleanup
|
||||
// can call std::fs::remove_file on the path.
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
|
||||
let path = std::env::temp_dir().join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
|
||||
let conn = Connection::open(&path)?;
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
// ignore unused mut warning for manager in tests
|
||||
@ -304,7 +300,12 @@ impl SessionStore {
|
||||
chat_id: &str,
|
||||
) -> Result<SessionRecord, StorageError> {
|
||||
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 的会话存在(如果不存在则创建)
|
||||
@ -512,7 +513,11 @@ impl SessionStore {
|
||||
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 conn = self.pool.get()?;
|
||||
conn.execute(
|
||||
@ -810,12 +815,7 @@ impl SessionStore {
|
||||
archived_at = NULL
|
||||
WHERE id = ?1 AND deleted_at IS NULL
|
||||
",
|
||||
params![
|
||||
session_id,
|
||||
inserted_count,
|
||||
active_user_turn_count,
|
||||
now,
|
||||
],
|
||||
params![session_id, inserted_count, active_user_turn_count, now,],
|
||||
)?;
|
||||
|
||||
tx.commit()?;
|
||||
@ -1583,7 +1583,8 @@ impl SessionStore {
|
||||
|
||||
/// 获取指定话题的消息数量(动态计算,确保准确)
|
||||
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> {
|
||||
@ -1619,10 +1620,7 @@ impl SessionStore {
|
||||
let now = current_timestamp();
|
||||
|
||||
// Delete existing todos for this scope_key
|
||||
tx.execute(
|
||||
"DELETE FROM todos WHERE scope_key = ?1",
|
||||
params![scope_key],
|
||||
)?;
|
||||
tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?;
|
||||
|
||||
// Insert new todos
|
||||
for item in items {
|
||||
@ -1669,7 +1667,7 @@ impl SessionStore {
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
drop(stmt); // 释放 stmt 借用,才能 commit
|
||||
drop(stmt); // 释放 stmt 借用,才能 commit
|
||||
tx.commit()?;
|
||||
Ok(result)
|
||||
}
|
||||
@ -1950,7 +1948,7 @@ fn load_messages_after(
|
||||
messages.push(row?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
|
||||
fn current_timestamp() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
@ -8,18 +8,38 @@ pub const GLOBAL_SCOPE_KEY: &str = "default";
|
||||
/// 每个命名空间代表一类记忆内容,用于分类管理和检索。
|
||||
/// 禁止使用未在此列表中的 namespace 创建记忆。
|
||||
pub const ALLOWED_MEMORY_NAMESPACES: &[(&str, &str)] = &[
|
||||
("user", "用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作"),
|
||||
("semantic", "语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累"),
|
||||
("episodic", "情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪"),
|
||||
("skill", "技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行"),
|
||||
("environment", "环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力"),
|
||||
("reflection", "反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进"),
|
||||
(
|
||||
"user",
|
||||
"用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作",
|
||||
),
|
||||
(
|
||||
"semantic",
|
||||
"语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累",
|
||||
),
|
||||
(
|
||||
"episodic",
|
||||
"情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪",
|
||||
),
|
||||
(
|
||||
"skill",
|
||||
"技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行",
|
||||
),
|
||||
(
|
||||
"environment",
|
||||
"环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力",
|
||||
),
|
||||
(
|
||||
"reflection",
|
||||
"反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进",
|
||||
),
|
||||
("other", "其他记忆:不属于以上分类的其他记忆内容"),
|
||||
];
|
||||
|
||||
/// 验证 namespace 是否在允许列表中
|
||||
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 的中文描述
|
||||
@ -32,7 +52,10 @@ pub fn get_namespace_description(namespace: &str) -> Option<&'static str> {
|
||||
|
||||
/// 获取所有允许的 namespace 名称列表(用于 JSON schema enum)
|
||||
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)]
|
||||
|
||||
@ -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 = serde_json::from_str(&payload_json).map_err(|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)
|
||||
.transpose()
|
||||
.map_err(|err| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
9,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(err),
|
||||
)
|
||||
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(err))
|
||||
})?;
|
||||
|
||||
Ok(ChatMessage {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use super::migrations::has_column;
|
||||
use super::*;
|
||||
use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT;
|
||||
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");
|
||||
// 幂等:已带前缀的 chat_id 会被清理,不会累积前缀
|
||||
assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc");
|
||||
assert_eq!(persistent_session_id("websocket", "websocket:websocket:abc"), "abc");
|
||||
assert_eq!(persistent_session_id(TEST_CHANNEL, "abc"), "test-channel:abc");
|
||||
assert_eq!(
|
||||
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]
|
||||
@ -76,8 +85,12 @@ fn test_session_store_roundtrip_and_lifecycle() {
|
||||
fn test_ensure_channel_session_is_stable() {
|
||||
let store = SessionStore::in_memory().unwrap();
|
||||
|
||||
let first = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap();
|
||||
let second = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap();
|
||||
let first = store
|
||||
.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.chat_id, "chat-1");
|
||||
@ -176,8 +189,7 @@ fn test_schema_migration_adds_user_turn_and_reinjection_columns() {
|
||||
|
||||
#[test]
|
||||
fn test_schema_migration_adds_reasoning_content_column_to_messages() {
|
||||
let tmp = std::env::temp_dir()
|
||||
.join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4()));
|
||||
let tmp = std::env::temp_dir().join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4()));
|
||||
let conn = Connection::open(&tmp).unwrap();
|
||||
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 session = store.create_cli_session(Some("compact-history")).unwrap();
|
||||
|
||||
let agent_prompt = ChatMessage::system_with_context(
|
||||
"agent",
|
||||
Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()),
|
||||
);
|
||||
let agent_prompt =
|
||||
ChatMessage::system_with_context("agent", Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()));
|
||||
let seed_messages = vec![
|
||||
agent_prompt.clone(),
|
||||
ChatMessage::user("u1"),
|
||||
@ -378,7 +388,10 @@ fn test_memory_roundtrip_with_source_fields() {
|
||||
|
||||
assert_eq!(saved.content, "Rust");
|
||||
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_seq, Some(7));
|
||||
|
||||
@ -474,7 +487,13 @@ fn test_memory_search_matches_memory_key_field() {
|
||||
.unwrap();
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
assert_eq!(
|
||||
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
|
||||
|
||||
@ -13,7 +13,7 @@ use tokio::time::{Instant, sleep_until};
|
||||
use crate::platform::{ShellInfo, dangerous_command_patterns};
|
||||
use crate::tools::shell_session::ShellSessionManager;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use crate::tools::{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_OUTPUT_CHARS: usize = 50_000;
|
||||
@ -84,7 +84,11 @@ impl ShellKind {
|
||||
/// 执行命令所需的参数
|
||||
pub fn command_args<'a>(&self, command: &'a str) -> Vec<&'a str> {
|
||||
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 {
|
||||
match self {
|
||||
ShellKind::Bash => "Execute a bash 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.",
|
||||
ShellKind::Bash => {
|
||||
"Execute a bash 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!(
|
||||
"{}\n{}{}\n\n{}",
|
||||
PENDING_USER_ACTION_MARKER,
|
||||
session_line,
|
||||
hint,
|
||||
output_section
|
||||
PENDING_USER_ACTION_MARKER, session_line, hint, output_section
|
||||
)
|
||||
}
|
||||
|
||||
@ -711,10 +718,7 @@ mod tests {
|
||||
} else {
|
||||
"echo 'Hello World'"
|
||||
};
|
||||
let result = tool
|
||||
.execute(json!({ "command": command }))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = tool.execute(json!({ "command": command })).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("Hello World"));
|
||||
@ -742,10 +746,7 @@ mod tests {
|
||||
} else {
|
||||
format!("ls -la {}", temp_dir.display())
|
||||
};
|
||||
let result = tool
|
||||
.execute(json!({ "command": command }))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = tool.execute(json!({ "command": command })).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
}
|
||||
@ -892,8 +893,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_shell_kind_command_args() {
|
||||
assert_eq!(ShellKind::Bash.command_args("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"]);
|
||||
assert_eq!(
|
||||
ShellKind::Bash.command_args("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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::tools::extract_f64 as extract_f64_opt;
|
||||
use crate::tools::check_null_args;
|
||||
use crate::tools::extract_f64 as extract_f64_opt;
|
||||
use async_trait::async_trait;
|
||||
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() {
|
||||
Ok(n)
|
||||
} 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 {
|
||||
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() {
|
||||
Ok(n)
|
||||
} 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 {
|
||||
Err(format!("{name} must be an integer"))
|
||||
}
|
||||
@ -755,7 +757,13 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
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]
|
||||
@ -763,6 +771,12 @@ mod tests {
|
||||
let tool = CalculatorTool::new();
|
||||
let result = tool.execute(serde_json::Value::Null).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("Missing required parameters"));
|
||||
assert!(
|
||||
result
|
||||
.error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("Missing required parameters")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@ use std::path::Path;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use crate::tools::extract_bool;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
|
||||
pub struct FileEditTool {
|
||||
allowed_dir: Option<String>,
|
||||
@ -43,21 +43,28 @@ impl FileEditTool {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
// File doesn't exist yet; canonicalize parent directory
|
||||
let parent = resolved.parent().ok_or_else(|| {
|
||||
format!("Path '{}' has no parent directory", path)
|
||||
})?;
|
||||
let parent = resolved
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Path '{}' has no parent directory", path))?;
|
||||
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(|| {
|
||||
format!("Path '{}' has no file name component", path)
|
||||
})?)
|
||||
canonical_parent.join(
|
||||
resolved
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("Path '{}' has no file name component", path))?,
|
||||
)
|
||||
}
|
||||
};
|
||||
if !canonical_resolved.starts_with(&canonical_allowed) {
|
||||
return Err(format!(
|
||||
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
||||
path, canonical_resolved.display(), canonical_allowed.display()
|
||||
path,
|
||||
canonical_resolved.display(),
|
||||
canonical_allowed.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@ use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::text::take_prefix_chars;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use crate::tools::extract_u64;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
|
||||
const MAX_CHARS: usize = 100_000;
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
@ -48,7 +48,9 @@ impl FileReadTool {
|
||||
if !canonical_resolved.starts_with(&canonical_allowed) {
|
||||
return Err(format!(
|
||||
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
||||
path, canonical_resolved.display(), canonical_allowed.display()
|
||||
path,
|
||||
canonical_resolved.display(),
|
||||
canonical_allowed.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,21 +42,28 @@ impl FileWriteTool {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
// File doesn't exist yet; canonicalize parent directory
|
||||
let parent = resolved.parent().ok_or_else(|| {
|
||||
format!("Path '{}' has no parent directory", path)
|
||||
})?;
|
||||
let parent = resolved
|
||||
.parent()
|
||||
.ok_or_else(|| format!("Path '{}' has no parent directory", path))?;
|
||||
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(|| {
|
||||
format!("Path '{}' has no file name component", path)
|
||||
})?)
|
||||
canonical_parent.join(
|
||||
resolved
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("Path '{}' has no file name component", path))?,
|
||||
)
|
||||
}
|
||||
};
|
||||
if !canonical_resolved.starts_with(&canonical_allowed) {
|
||||
return Err(format!(
|
||||
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
||||
path, canonical_resolved.display(), canonical_allowed.display()
|
||||
path,
|
||||
canonical_resolved.display(),
|
||||
canonical_allowed.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
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};
|
||||
|
||||
pub struct MemoryManageTool {
|
||||
|
||||
@ -4,8 +4,8 @@ use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::storage::{MemoryRecord, MemoryRepository};
|
||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||
use crate::tools::extract_u64;
|
||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||
|
||||
pub struct MemorySearchTool {
|
||||
memories: Arc<dyn MemoryRepository>,
|
||||
@ -103,8 +103,7 @@ impl Tool for MemorySearchTool {
|
||||
Some(value) => {
|
||||
// 支持两种格式:实际数组 或 字符串化的数组
|
||||
if let Some(arr) = value.as_array() {
|
||||
arr
|
||||
.iter()
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
@ -133,7 +132,7 @@ impl Tool for MemorySearchTool {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
None => vec![]
|
||||
None => vec![],
|
||||
};
|
||||
if queries.is_empty() {
|
||||
return Ok(error_result("Missing required parameter: queries"));
|
||||
|
||||
@ -8,8 +8,8 @@ pub mod memory_manage;
|
||||
pub mod memory_search;
|
||||
pub mod registry;
|
||||
pub mod scheduler_manage;
|
||||
pub mod session_send;
|
||||
pub mod schema;
|
||||
pub mod session_send;
|
||||
pub mod shell_session;
|
||||
pub mod skill_activate;
|
||||
pub mod skill_manage;
|
||||
@ -30,11 +30,11 @@ pub use memory_manage::MemoryManageTool;
|
||||
pub use memory_search::MemorySearchTool;
|
||||
pub use registry::ToolRegistry;
|
||||
pub use scheduler_manage::SchedulerManageTool;
|
||||
pub use session_send::{
|
||||
NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest,
|
||||
SessionSendTool,
|
||||
};
|
||||
pub use schema::{CleaningStrategy, SchemaCleanr};
|
||||
pub use session_send::{
|
||||
NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest,
|
||||
SessionSendTool,
|
||||
};
|
||||
pub use shell_session::ShellSessionManager;
|
||||
pub use skill_activate::SkillActivateTool;
|
||||
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.
|
||||
pub fn require_f64(args: &serde_json::Value, key: &str) -> Result<f64, String> {
|
||||
extract_f64(args, key)
|
||||
.ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||
extract_f64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||
}
|
||||
|
||||
/// Extract a required i64 parameter, returning an error message if missing.
|
||||
pub fn require_i64(args: &serde_json::Value, key: &str) -> Result<i64, String> {
|
||||
extract_i64(args, key)
|
||||
.ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||
extract_i64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||
}
|
||||
|
||||
/// Extract a required u64 parameter, returning an error message if missing.
|
||||
pub fn require_u64(args: &serde_json::Value, key: &str) -> Result<u64, String> {
|
||||
extract_u64(args, key)
|
||||
.ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||
extract_u64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||
}
|
||||
|
||||
/// Extract a required bool parameter, returning an error message if missing.
|
||||
pub fn require_bool(args: &serde_json::Value, key: &str) -> Result<bool, String> {
|
||||
extract_bool(args, key)
|
||||
.ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||
extract_bool(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||
}
|
||||
|
||||
/// 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!(
|
||||
"Invalid parameters: {} expects a JSON object, got {}",
|
||||
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"
|
||||
}
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
@ -59,7 +59,8 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
pub fn has_tools(&self) -> bool {
|
||||
!self.tools
|
||||
!self
|
||||
.tools
|
||||
.read()
|
||||
.expect("ToolRegistry lock poisoned")
|
||||
.is_empty()
|
||||
@ -84,7 +85,10 @@ impl ToolRegistry {
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
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
|
||||
}
|
||||
|
||||
@ -99,7 +103,10 @@ impl ToolRegistry {
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,13 +132,7 @@ impl Tool for SessionSendTool {
|
||||
|
||||
let outcome = match self
|
||||
.sender
|
||||
.send_to_current_session(
|
||||
context,
|
||||
SessionSendRequest {
|
||||
text,
|
||||
attachments,
|
||||
},
|
||||
)
|
||||
.send_to_current_session(context, SessionSendRequest { text, attachments })
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
@ -154,7 +148,12 @@ impl Tool for SessionSendTool {
|
||||
}
|
||||
|
||||
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!(
|
||||
"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>> {
|
||||
// 支持两种格式:实际数组 或 字符串化的 JSON 数组
|
||||
let paths = if let Some(arr) = value.as_array() {
|
||||
arr
|
||||
.iter()
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
@ -565,7 +563,10 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
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]
|
||||
@ -597,8 +598,8 @@ mod tests {
|
||||
let image_path = file.path().with_extension("png");
|
||||
std::fs::rename(file.path(), &image_path).unwrap();
|
||||
|
||||
let attachments = parse_attachments(&json!([image_path.to_string_lossy().to_string()]))
|
||||
.unwrap();
|
||||
let attachments =
|
||||
parse_attachments(&json!([image_path.to_string_lossy().to_string()])).unwrap();
|
||||
|
||||
assert_eq!(attachments.len(), 1);
|
||||
assert_eq!(attachments[0].media_type, "image");
|
||||
@ -651,4 +652,4 @@ mod tests {
|
||||
// 验证文件名能正确提取(用 lossy 方式,因为是 GBK 编码)
|
||||
assert!(attachments[0].file_name.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ use serde_json::json;
|
||||
|
||||
use crate::skills::SkillRuntime;
|
||||
use crate::storage::SkillEventRepository;
|
||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||
use crate::tools::check_null_args;
|
||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||
|
||||
pub struct SkillActivateTool {
|
||||
skills: Arc<SkillRuntime>,
|
||||
@ -135,7 +135,9 @@ mod tests {
|
||||
async fn test_skill_activate_records_failed_activation_event() {
|
||||
let skills = Arc::new(SkillRuntime::default());
|
||||
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 context = ToolContext {
|
||||
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
||||
@ -162,7 +164,9 @@ mod tests {
|
||||
async fn test_skill_activate_handles_null_args() {
|
||||
let skills = Arc::new(SkillRuntime::default());
|
||||
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 context = ToolContext {
|
||||
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
||||
@ -175,6 +179,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(result.error.unwrap().contains("Missing required parameters"));
|
||||
assert!(
|
||||
result
|
||||
.error
|
||||
.unwrap()
|
||||
.contains("Missing required parameters")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,4 +45,4 @@ impl TaskError {
|
||||
Self::InvalidArguments(_) => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,12 @@ pub mod types;
|
||||
pub use error::TaskError;
|
||||
pub use prompt::SubagentPromptBuilder;
|
||||
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 types::{SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession, TaskSessionState, TaskToolArgs, TaskToolResult};
|
||||
pub use types::{
|
||||
SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession,
|
||||
TaskSessionState, TaskToolArgs, TaskToolResult,
|
||||
};
|
||||
|
||||
@ -135,4 +135,4 @@ fn current_timestamp() -> i64 {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock before unix epoch")
|
||||
.as_millis() as i64
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user