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"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
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]
|
[dependencies]
|
||||||
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "multipart", "stream"] }
|
reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls", "multipart", "stream"] }
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
|
|||||||
19
Makefile
19
Makefile
@ -1,6 +1,6 @@
|
|||||||
# PicoBot Web UI Makefile
|
# PicoBot Web UI Makefile
|
||||||
|
|
||||||
.PHONY: dev dev-backend dev-frontend build clean install
|
.PHONY: dev dev-backend dev-frontend build clean install check fmt fix help
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
all: build
|
all: build
|
||||||
@ -47,11 +47,22 @@ clean:
|
|||||||
|
|
||||||
# Check code formatting and linting
|
# Check code formatting and linting
|
||||||
check:
|
check:
|
||||||
@echo "Checking frontend..."
|
@echo "Checking formatting..."
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
@echo "Checking frontend (lint + build)..."
|
||||||
|
cd web && npm run lint
|
||||||
cd web && npm run build
|
cd web && npm run build
|
||||||
@echo "Checking Rust code..."
|
@echo "Checking Rust code..."
|
||||||
cargo check
|
cargo check
|
||||||
cargo clippy
|
cargo clippy --all-targets --all-features
|
||||||
|
|
||||||
|
# Format all Rust code in place
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all
|
||||||
|
|
||||||
|
# Auto-fix clippy lints where possible
|
||||||
|
fix:
|
||||||
|
cargo clippy --fix --all-targets --allow-dirty --allow-no-vcs
|
||||||
|
|
||||||
# Help
|
# Help
|
||||||
help:
|
help:
|
||||||
@ -66,4 +77,6 @@ help:
|
|||||||
@echo " make run - Run production build"
|
@echo " make run - Run production build"
|
||||||
@echo " make clean - Clean build artifacts"
|
@echo " make clean - Clean build artifacts"
|
||||||
@echo " make check - Check code formatting and linting"
|
@echo " make check - Check code formatting and linting"
|
||||||
|
@echo " make fmt - Format all Rust code in place"
|
||||||
|
@echo " make fix - Auto-fix clippy lints where possible"
|
||||||
@echo " make help - Show this help message"
|
@echo " make help - Show this help message"
|
||||||
|
|||||||
12
rustfmt.toml
Normal file
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::agent::{SystemPromptContext, SystemPromptProvider};
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
use crate::bus::message::ToolMessageState;
|
use crate::bus::message::ToolMessageState;
|
||||||
use crate::storage::ConversationRepository;
|
|
||||||
use crate::domain::messages::{ContentBlock, ToolCall};
|
use crate::domain::messages::{ContentBlock, ToolCall};
|
||||||
use crate::observability::{
|
use crate::observability::{
|
||||||
Observer, ObserverEvent, ToolExecutionOutcome, ToolExecutionState, truncate_args,
|
Observer, ObserverEvent, ToolExecutionOutcome, ToolExecutionState, truncate_args,
|
||||||
};
|
};
|
||||||
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, StreamDelta, StreamCallback, create_provider};
|
use crate::providers::{
|
||||||
|
ChatCompletionRequest, LLMProvider, Message, StreamCallback, StreamDelta, create_provider,
|
||||||
|
};
|
||||||
|
use crate::storage::ConversationRepository;
|
||||||
use crate::text::{char_count, take_prefix_chars, take_suffix_chars};
|
use crate::text::{char_count, take_prefix_chars, take_suffix_chars};
|
||||||
use crate::tools::{ToolContext, ToolRegistry};
|
use crate::tools::{ToolContext, ToolRegistry};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -254,7 +256,9 @@ fn filter_images_by_age_and_count(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 计算这条消息中的图片数量
|
// 计算这条消息中的图片数量
|
||||||
let image_count_in_msg = message.media_refs.iter()
|
let image_count_in_msg = message
|
||||||
|
.media_refs
|
||||||
|
.iter()
|
||||||
.filter(|p| supported_image_mime_type(p).is_some())
|
.filter(|p| supported_image_mime_type(p).is_some())
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
@ -284,7 +288,9 @@ fn filter_images_by_age_and_count(
|
|||||||
|
|
||||||
// 过滤图片:保留非图片媒体和指定数量的图片
|
// 过滤图片:保留非图片媒体和指定数量的图片
|
||||||
let mut images_kept_in_msg = 0usize;
|
let mut images_kept_in_msg = 0usize;
|
||||||
let filtered_media_refs: Vec<String> = message.media_refs.iter()
|
let filtered_media_refs: Vec<String> = message
|
||||||
|
.media_refs
|
||||||
|
.iter()
|
||||||
.filter_map(|path| {
|
.filter_map(|path| {
|
||||||
if supported_image_mime_type(path).is_some() {
|
if supported_image_mime_type(path).is_some() {
|
||||||
if images_kept_in_msg < keep_count {
|
if images_kept_in_msg < keep_count {
|
||||||
@ -300,16 +306,22 @@ fn filter_images_by_age_and_count(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 如果图片被过滤,添加文本提示
|
// 如果图片被过滤,添加文本提示
|
||||||
let original_image_count = message.media_refs.iter()
|
let original_image_count = message
|
||||||
|
.media_refs
|
||||||
|
.iter()
|
||||||
.filter(|p| supported_image_mime_type(p).is_some())
|
.filter(|p| supported_image_mime_type(p).is_some())
|
||||||
.count();
|
.count();
|
||||||
let filtered_image_count = filtered_media_refs.iter()
|
let filtered_image_count = filtered_media_refs
|
||||||
|
.iter()
|
||||||
.filter(|p| supported_image_mime_type(p).is_some())
|
.filter(|p| supported_image_mime_type(p).is_some())
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
let content = if original_image_count > filtered_image_count {
|
let content = if original_image_count > filtered_image_count {
|
||||||
let notice = if exceeds_age_limit {
|
let notice = if exceeds_age_limit {
|
||||||
format!("{} [图片已过期:超出 {} 条消息范围]", message.content, max_age_rounds)
|
format!(
|
||||||
|
"{} [图片已过期:超出 {} 条消息范围]",
|
||||||
|
message.content, max_age_rounds
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{} [图片已过期:超出最大图片数量限制]", message.content)
|
format!("{} [图片已过期:超出最大图片数量限制]", message.content)
|
||||||
};
|
};
|
||||||
@ -705,7 +717,12 @@ impl<H: EmittedMessageHandler> PersistingEmittedMessageHandler<H> {
|
|||||||
session_id: impl Into<String>,
|
session_id: impl Into<String>,
|
||||||
topic_id: Option<String>,
|
topic_id: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { inner, conversation_repository, session_id: session_id.into(), topic_id }
|
Self {
|
||||||
|
inner,
|
||||||
|
conversation_repository,
|
||||||
|
session_id: session_id.into(),
|
||||||
|
topic_id,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -720,11 +737,9 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
|
|||||||
let topic_id = self.topic_id.clone();
|
let topic_id = self.topic_id.clone();
|
||||||
let msg_for_persist = message.clone();
|
let msg_for_persist = message.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
if let Err(e) = repo.append_message_with_topic(
|
if let Err(e) =
|
||||||
&session_id,
|
repo.append_message_with_topic(&session_id, topic_id.as_deref(), &msg_for_persist)
|
||||||
topic_id.as_deref(),
|
{
|
||||||
&msg_for_persist,
|
|
||||||
) {
|
|
||||||
tracing::error!(error = %e, session_id = %session_id,
|
tracing::error!(error = %e, session_id = %session_id,
|
||||||
"Failed to persist emitted message");
|
"Failed to persist emitted message");
|
||||||
}
|
}
|
||||||
@ -741,11 +756,9 @@ impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessag
|
|||||||
let topic_id = self.topic_id.clone();
|
let topic_id = self.topic_id.clone();
|
||||||
let msg_for_persist = message.clone();
|
let msg_for_persist = message.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
if let Err(e) = repo.append_message_with_topic(
|
if let Err(e) =
|
||||||
&session_id,
|
repo.append_message_with_topic(&session_id, topic_id.as_deref(), &msg_for_persist)
|
||||||
topic_id.as_deref(),
|
{
|
||||||
&msg_for_persist,
|
|
||||||
) {
|
|
||||||
tracing::error!(error = %e, session_id = %session_id,
|
tracing::error!(error = %e, session_id = %session_id,
|
||||||
"Failed to persist emitted message");
|
"Failed to persist emitted message");
|
||||||
}
|
}
|
||||||
@ -925,13 +938,15 @@ impl AgentLoop {
|
|||||||
// Sanitize: remove any trailing incomplete tool call sequences
|
// Sanitize: remove any trailing incomplete tool call sequences
|
||||||
// that may have been persisted before a process interruption.
|
// that may have been persisted before a process interruption.
|
||||||
{
|
{
|
||||||
let tool_call_ids: Vec<_> = messages.iter()
|
let tool_call_ids: Vec<_> = messages
|
||||||
|
.iter()
|
||||||
.filter(|m| m.role == "assistant")
|
.filter(|m| m.role == "assistant")
|
||||||
.filter_map(|m| m.tool_calls.as_ref())
|
.filter_map(|m| m.tool_calls.as_ref())
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|tc| tc.id.clone())
|
.map(|tc| tc.id.clone())
|
||||||
.collect();
|
.collect();
|
||||||
let tool_result_ids: Vec<_> = messages.iter()
|
let tool_result_ids: Vec<_> = messages
|
||||||
|
.iter()
|
||||||
.filter(|m| m.role == "tool")
|
.filter(|m| m.role == "tool")
|
||||||
.filter_map(|m| m.tool_call_id.clone())
|
.filter_map(|m| m.tool_call_id.clone())
|
||||||
.collect();
|
.collect();
|
||||||
@ -982,7 +997,8 @@ impl AgentLoop {
|
|||||||
if self.check_cancelled().await {
|
if self.check_cancelled().await {
|
||||||
tracing::info!(iteration, "Agent execution cancelled by user");
|
tracing::info!(iteration, "Agent execution cancelled by user");
|
||||||
let cancel = Self::build_cancel_result(iteration, emitted_messages);
|
let cancel = Self::build_cancel_result(iteration, emitted_messages);
|
||||||
self.emit_live_tool_call_message(cancel.final_response.clone()).await;
|
self.emit_live_tool_call_message(cancel.final_response.clone())
|
||||||
|
.await;
|
||||||
return Ok(cancel);
|
return Ok(cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1000,7 +1016,12 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = self.build_llm_request(&messages, system_prompt_context, tools.clone(), tools_tokens);
|
let request = self.build_llm_request(
|
||||||
|
&messages,
|
||||||
|
system_prompt_context,
|
||||||
|
tools.clone(),
|
||||||
|
tools_tokens,
|
||||||
|
);
|
||||||
|
|
||||||
// Set up streaming delta consumer
|
// Set up streaming delta consumer
|
||||||
// Pre-generate the message ID so stream deltas and the final assistant
|
// Pre-generate the message ID so stream deltas and the final assistant
|
||||||
@ -1054,7 +1075,10 @@ impl AgentLoop {
|
|||||||
drop(stream_callback);
|
drop(stream_callback);
|
||||||
} else {
|
} else {
|
||||||
// 无取消令牌:stream_callback 被 move 进 chat_with_streaming,调用完成即释放。
|
// 无取消令牌:stream_callback 被 move 进 chat_with_streaming,调用完成即释放。
|
||||||
llm_result = self.provider.chat_with_streaming(request, stream_callback).await;
|
llm_result = self
|
||||||
|
.provider
|
||||||
|
.chat_with_streaming(request, stream_callback)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close delta channel and wait for consumer to finish processing
|
// Close delta channel and wait for consumer to finish processing
|
||||||
@ -1074,7 +1098,8 @@ impl AgentLoop {
|
|||||||
let assistant_message =
|
let assistant_message =
|
||||||
ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
self.emit_live_tool_call_message(assistant_message.clone()).await;
|
self.emit_live_tool_call_message(assistant_message.clone())
|
||||||
|
.await;
|
||||||
return Ok(AgentProcessResult {
|
return Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
@ -1104,9 +1129,14 @@ impl AgentLoop {
|
|||||||
|
|
||||||
// If no tool calls, this is the final response
|
// If no tool calls, this is the final response
|
||||||
if response.tool_calls.is_empty() {
|
if response.tool_calls.is_empty() {
|
||||||
let result = self.build_final_response(
|
let result = self
|
||||||
response, &streaming_message_id, had_streaming, &mut emitted_messages,
|
.build_final_response(
|
||||||
).await;
|
response,
|
||||||
|
&streaming_message_id,
|
||||||
|
had_streaming,
|
||||||
|
&mut emitted_messages,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1177,9 +1207,13 @@ impl AgentLoop {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.process_tool_results(
|
self.process_tool_results(
|
||||||
&response.tool_calls, &tool_results, &mut loop_detector,
|
&response.tool_calls,
|
||||||
&mut messages, &mut emitted_messages,
|
&tool_results,
|
||||||
).await;
|
&mut loop_detector,
|
||||||
|
&mut messages,
|
||||||
|
&mut emitted_messages,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
// Loop continues to next iteration with updated messages
|
// Loop continues to next iteration with updated messages
|
||||||
// PendingUserAction 工具的结果已在上方加入 messages,
|
// PendingUserAction 工具的结果已在上方加入 messages,
|
||||||
@ -1193,7 +1227,9 @@ impl AgentLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Max iterations reached - request final summary from LLM
|
// Max iterations reached - request final summary from LLM
|
||||||
Ok(self.run_final_summary(&mut messages, system_prompt_context, &mut emitted_messages).await)
|
Ok(self
|
||||||
|
.run_final_summary(&mut messages, system_prompt_context, &mut emitted_messages)
|
||||||
|
.await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 等待取消信号。若未配置 cancel_token,永远不返回。
|
/// 等待取消信号。若未配置 cancel_token,永远不返回。
|
||||||
@ -1255,11 +1291,8 @@ impl AgentLoop {
|
|||||||
&filtered_messages,
|
&filtered_messages,
|
||||||
system_prompt.as_ref().map(|p| p.content.as_str()),
|
system_prompt.as_ref().map(|p| p.content.as_str()),
|
||||||
);
|
);
|
||||||
let image_tokens = image_token_budget_for_request(
|
let image_tokens =
|
||||||
&self.runtime_config,
|
image_token_budget_for_request(&self.runtime_config, text_tokens, tools_tokens);
|
||||||
text_tokens,
|
|
||||||
tools_tokens,
|
|
||||||
);
|
|
||||||
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
|
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
|
||||||
|
|
||||||
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
|
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
|
||||||
@ -1299,7 +1332,8 @@ impl AgentLoop {
|
|||||||
assistant_message.id = streaming_message_id.to_string();
|
assistant_message.id = streaming_message_id.to_string();
|
||||||
}
|
}
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
self.emit_live_tool_call_message(assistant_message.clone()).await;
|
self.emit_live_tool_call_message(assistant_message.clone())
|
||||||
|
.await;
|
||||||
AgentProcessResult {
|
AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages: std::mem::take(emitted_messages),
|
emitted_messages: std::mem::take(emitted_messages),
|
||||||
@ -1360,7 +1394,10 @@ impl AgentLoop {
|
|||||||
// Defense: sanitize before final summary request
|
// Defense: sanitize before final summary request
|
||||||
let removed = Self::sanitize_messages_for_llm(messages);
|
let removed = Self::sanitize_messages_for_llm(messages);
|
||||||
if removed > 0 {
|
if removed > 0 {
|
||||||
tracing::warn!(removed_count = removed, "Sanitized before max-iterations summary");
|
tracing::warn!(
|
||||||
|
removed_count = removed,
|
||||||
|
"Sanitized before max-iterations summary"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a message asking for summary
|
// Add a message asking for summary
|
||||||
@ -1394,13 +1431,15 @@ impl AgentLoop {
|
|||||||
|
|
||||||
match final_result {
|
match final_result {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let assistant_message = if let Some(reasoning_content) = response.reasoning_content {
|
let assistant_message = if let Some(reasoning_content) = response.reasoning_content
|
||||||
|
{
|
||||||
ChatMessage::assistant_with_reasoning(response.content, reasoning_content)
|
ChatMessage::assistant_with_reasoning(response.content, reasoning_content)
|
||||||
} else {
|
} else {
|
||||||
ChatMessage::assistant(response.content)
|
ChatMessage::assistant(response.content)
|
||||||
};
|
};
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
self.emit_live_tool_call_message(assistant_message.clone()).await;
|
self.emit_live_tool_call_message(assistant_message.clone())
|
||||||
|
.await;
|
||||||
AgentProcessResult {
|
AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages: std::mem::take(emitted_messages),
|
emitted_messages: std::mem::take(emitted_messages),
|
||||||
@ -1416,7 +1455,8 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
let final_message = ChatMessage::assistant(recoverable_llm_message(&e.to_string()));
|
||||||
emitted_messages.push(final_message.clone());
|
emitted_messages.push(final_message.clone());
|
||||||
self.emit_live_tool_call_message(final_message.clone()).await;
|
self.emit_live_tool_call_message(final_message.clone())
|
||||||
|
.await;
|
||||||
AgentProcessResult {
|
AgentProcessResult {
|
||||||
final_response: final_message,
|
final_response: final_message,
|
||||||
emitted_messages: std::mem::take(emitted_messages),
|
emitted_messages: std::mem::take(emitted_messages),
|
||||||
@ -1533,9 +1573,7 @@ impl AgentLoop {
|
|||||||
// Log function call with name and arguments before execution
|
// Log function call with name and arguments before execution
|
||||||
let args_str = match &tool_call.arguments {
|
let args_str = match &tool_call.arguments {
|
||||||
serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(),
|
serde_json::Value::Object(obj) if obj.is_empty() => "{}".to_string(),
|
||||||
other => {
|
other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()),
|
||||||
serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string())
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool");
|
tracing::info!(tool = %tool_call.name, args = %args_str, "Calling tool");
|
||||||
|
|
||||||
@ -1571,7 +1609,8 @@ impl AgentLoop {
|
|||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(tool = %tool_call.name, "Tool not found");
|
tracing::warn!(tool = %tool_call.name, "Tool not found");
|
||||||
let skill_hint = self.skills
|
let skill_hint = self
|
||||||
|
.skills
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|s| s.matching_skill_summary(&tool_call.name));
|
.and_then(|s| s.matching_skill_summary(&tool_call.name));
|
||||||
let error = match skill_hint {
|
let error = match skill_hint {
|
||||||
@ -1581,10 +1620,7 @@ impl AgentLoop {
|
|||||||
),
|
),
|
||||||
None => format!("Tool '{}' not found", tool_call.name),
|
None => format!("Tool '{}' not found", tool_call.name),
|
||||||
};
|
};
|
||||||
return ToolExecutionOutcome::failure(
|
return ToolExecutionOutcome::failure(format!("Error: {}", error), Some(error));
|
||||||
format!("Error: {}", error),
|
|
||||||
Some(error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1977,10 +2013,7 @@ mod tests {
|
|||||||
// 创建 3 条消息,每条都有图片
|
// 创建 3 条消息,每条都有图片
|
||||||
let messages: Vec<ChatMessage> = (0..3)
|
let messages: Vec<ChatMessage> = (0..3)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
ChatMessage::user_with_media(
|
ChatMessage::user_with_media(format!("message {}", i), vec![jpg_paths[i].clone()])
|
||||||
format!("message {}", i),
|
|
||||||
vec![jpg_paths[i].clone()],
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@ -2148,7 +2181,8 @@ mod tests {
|
|||||||
// Missing tool result for call_2
|
// Missing tool result for call_2
|
||||||
];
|
];
|
||||||
|
|
||||||
let removed_count = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed_count =
|
||||||
|
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
// Phase 1 removes the assistant message (call_2 has no result).
|
// Phase 1 removes the assistant message (call_2 has no result).
|
||||||
// Phase 2 removes the orphaned tool result for call_1 (its parent
|
// Phase 2 removes the orphaned tool result for call_1 (its parent
|
||||||
// assistant was removed).
|
// assistant was removed).
|
||||||
@ -2181,9 +2215,7 @@ mod tests {
|
|||||||
fn test_sanitize_removes_orphaned_tool_messages() {
|
fn test_sanitize_removes_orphaned_tool_messages() {
|
||||||
// A lone tool message without a preceding assistant tool_calls
|
// A lone tool message without a preceding assistant tool_calls
|
||||||
// is orphaned and should be removed.
|
// is orphaned and should be removed.
|
||||||
let mut messages = vec![
|
let mut messages = vec![ChatMessage::tool("call_1", "calculator", "2")];
|
||||||
ChatMessage::tool("call_1", "calculator", "2"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 1);
|
assert_eq!(removed, 1);
|
||||||
@ -2398,7 +2430,6 @@ mod tests {
|
|||||||
ChatMessage::tool("t1_call", "read", "content A"),
|
ChatMessage::tool("t1_call", "read", "content A"),
|
||||||
ChatMessage::assistant("task 1 is done"),
|
ChatMessage::assistant("task 1 is done"),
|
||||||
// End of task 1 — complete sequence
|
// End of task 1 — complete sequence
|
||||||
|
|
||||||
ChatMessage::user("task 2"),
|
ChatMessage::user("task 2"),
|
||||||
ChatMessage::assistant_with_tool_calls(
|
ChatMessage::assistant_with_tool_calls(
|
||||||
"doing task 2 — this got interrupted",
|
"doing task 2 — this got interrupted",
|
||||||
@ -2417,7 +2448,6 @@ mod tests {
|
|||||||
),
|
),
|
||||||
// Missing BOTH tool results — process was killed here
|
// Missing BOTH tool results — process was killed here
|
||||||
// End of task 2 — orphaned sequence in the middle
|
// End of task 2 — orphaned sequence in the middle
|
||||||
|
|
||||||
ChatMessage::user("task 3"),
|
ChatMessage::user("task 3"),
|
||||||
ChatMessage::assistant_with_tool_calls(
|
ChatMessage::assistant_with_tool_calls(
|
||||||
"doing task 3",
|
"doing task 3",
|
||||||
@ -2526,11 +2556,19 @@ mod tests {
|
|||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
// The assistant should be removed (tool_calls stripped via removal)
|
// The assistant should be removed (tool_calls stripped via removal)
|
||||||
// and the orphaned tool(A) should also be removed
|
// and the orphaned tool(A) should also be removed
|
||||||
assert!(removed >= 2, "should remove both the assistant and orphaned tool message, got {}", removed);
|
assert!(
|
||||||
|
removed >= 2,
|
||||||
|
"should remove both the assistant and orphaned tool message, got {}",
|
||||||
|
removed
|
||||||
|
);
|
||||||
assert_eq!(messages.len(), 1, "only the user message should remain");
|
assert_eq!(messages.len(), 1, "only the user message should remain");
|
||||||
assert_eq!(messages[0].role, "user");
|
assert_eq!(messages[0].role, "user");
|
||||||
assert!(messages.iter().all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
|
assert!(
|
||||||
"no assistant should have tool_calls remaining");
|
messages
|
||||||
|
.iter()
|
||||||
|
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
|
||||||
|
"no assistant should have tool_calls remaining"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -2550,7 +2588,10 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
let removed = crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut messages);
|
||||||
assert_eq!(removed, 0, "should not remove anything — tool result immediately follows");
|
assert_eq!(
|
||||||
|
removed, 0,
|
||||||
|
"should not remove anything — tool result immediately follows"
|
||||||
|
);
|
||||||
assert_eq!(messages.len(), 3);
|
assert_eq!(messages.len(), 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
use crate::agent::{AgentError, AgentRuntimeConfig};
|
||||||
use crate::bus::{
|
use crate::bus::{
|
||||||
ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION,
|
ChatMessage, SYSTEM_CONTEXT_AGENT_PROMPT, SYSTEM_CONTEXT_HISTORY_COMPACTION,
|
||||||
SYSTEM_CONTEXT_SCHEDULED_PROMPT,
|
SYSTEM_CONTEXT_SCHEDULED_PROMPT,
|
||||||
@ -5,7 +6,6 @@ use crate::bus::{
|
|||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider};
|
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, create_provider};
|
||||||
use crate::text::{char_count, take_prefix_chars};
|
use crate::text::{char_count, take_prefix_chars};
|
||||||
use crate::agent::{AgentError, AgentRuntimeConfig};
|
|
||||||
|
|
||||||
const TOKEN_ESTIMATE_SAFETY_MULTIPLIER: f64 = 1.2;
|
const TOKEN_ESTIMATE_SAFETY_MULTIPLIER: f64 = 1.2;
|
||||||
const CJK_CHARS_PER_TOKEN: f64 = 2.0;
|
const CJK_CHARS_PER_TOKEN: f64 = 2.0;
|
||||||
@ -50,9 +50,9 @@ impl HistoryUnit {
|
|||||||
/// Estimate tokens for this unit alone.
|
/// Estimate tokens for this unit alone.
|
||||||
fn estimate_tokens(&self) -> usize {
|
fn estimate_tokens(&self) -> usize {
|
||||||
match self {
|
match self {
|
||||||
HistoryUnit::SystemGuard(msg) | HistoryUnit::UserMessage(msg) | HistoryUnit::AssistantText(msg) => {
|
HistoryUnit::SystemGuard(msg)
|
||||||
estimate_tokens(std::slice::from_ref(msg))
|
| HistoryUnit::UserMessage(msg)
|
||||||
}
|
| HistoryUnit::AssistantText(msg) => estimate_tokens(std::slice::from_ref(msg)),
|
||||||
HistoryUnit::ToolRound { assistant, results } => {
|
HistoryUnit::ToolRound { assistant, results } => {
|
||||||
let mut all = vec![assistant.clone()];
|
let mut all = vec![assistant.clone()];
|
||||||
all.extend(results.clone());
|
all.extend(results.clone());
|
||||||
@ -162,8 +162,8 @@ pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Weighted token calculation: CJK chars need more tokens per character
|
// Weighted token calculation: CJK chars need more tokens per character
|
||||||
let content_tokens = (cjk_count as f64 / CJK_CHARS_PER_TOKEN)
|
let content_tokens =
|
||||||
+ (other_count as f64 / OTHER_CHARS_PER_TOKEN);
|
(cjk_count as f64 / CJK_CHARS_PER_TOKEN) + (other_count as f64 / OTHER_CHARS_PER_TOKEN);
|
||||||
|
|
||||||
// JSON serialization overhead for message structure (fields, brackets, etc.)
|
// JSON serialization overhead for message structure (fields, brackets, etc.)
|
||||||
let json_overhead = messages.len() * JSON_OVERHEAD_PER_MESSAGE;
|
let json_overhead = messages.len() * JSON_OVERHEAD_PER_MESSAGE;
|
||||||
@ -1114,9 +1114,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_estimate_tokens_mixed_content() {
|
fn test_estimate_tokens_mixed_content() {
|
||||||
let messages = vec![
|
let messages = vec![ChatMessage::user("Hello 世界 this is 测试")];
|
||||||
ChatMessage::user("Hello 世界 this is 测试"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let tokens = estimate_tokens(&messages);
|
let tokens = estimate_tokens(&messages);
|
||||||
// Content: 18 English chars + 4 CJK chars
|
// Content: 18 English chars + 4 CJK chars
|
||||||
@ -1408,8 +1406,12 @@ mod tests {
|
|||||||
// All units are AssistantText, split at 50% token ratio
|
// All units are AssistantText, split at 50% token ratio
|
||||||
let split = compressor.find_safe_split_point(&units, 0.5);
|
let split = compressor.find_safe_split_point(&units, 0.5);
|
||||||
// Should split somewhere in the middle (not 0, not len())
|
// Should split somewhere in the middle (not 0, not len())
|
||||||
assert!(split > 0 && split < units.len(),
|
assert!(
|
||||||
"split {} should be between 0 and {}", split, units.len());
|
split > 0 && split < units.len(),
|
||||||
|
"split {} should be between 0 and {}",
|
||||||
|
split,
|
||||||
|
units.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1458,10 +1460,14 @@ mod tests {
|
|||||||
assert_eq!(compressed.len(), 3);
|
assert_eq!(compressed.len(), 3);
|
||||||
// Critical invariant: NO tool_calls or tool_call_id anywhere
|
// Critical invariant: NO tool_calls or tool_call_id anywhere
|
||||||
for msg in &compressed {
|
for msg in &compressed {
|
||||||
assert!(msg.tool_calls.is_none(),
|
assert!(
|
||||||
"compress_two_segment output should never contain tool_calls");
|
msg.tool_calls.is_none(),
|
||||||
assert!(msg.tool_call_id.is_none(),
|
"compress_two_segment output should never contain tool_calls"
|
||||||
"compress_two_segment output should never contain tool_call_id");
|
);
|
||||||
|
assert!(
|
||||||
|
msg.tool_call_id.is_none(),
|
||||||
|
"compress_two_segment output should never contain tool_call_id"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,6 @@ pub use agent_loop::{
|
|||||||
pub use context_compressor::ContextCompressor;
|
pub use context_compressor::ContextCompressor;
|
||||||
pub use runtime_config::AgentRuntimeConfig;
|
pub use runtime_config::AgentRuntimeConfig;
|
||||||
pub use system_prompt::{
|
pub use system_prompt::{
|
||||||
CompositeSystemPromptProvider, generate_system_env_prompt, SystemPrompt, SystemPromptContext,
|
CompositeSystemPromptProvider, SystemPrompt, SystemPromptContext, SystemPromptProvider,
|
||||||
SystemPromptProvider,
|
generate_system_env_prompt,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -284,12 +284,13 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
if msg.role == "assistant"
|
if msg.role == "assistant"
|
||||||
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
|
&& msg
|
||||||
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |calls| !calls.is_empty())
|
||||||
{
|
{
|
||||||
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
||||||
let all_have_results = tool_calls
|
let all_have_results = tool_calls.iter().all(|tc| resolved_ids.contains(&tc.id));
|
||||||
.iter()
|
|
||||||
.all(|tc| resolved_ids.contains(&tc.id));
|
|
||||||
|
|
||||||
if all_have_results {
|
if all_have_results {
|
||||||
for tc in tool_calls.iter() {
|
for tc in tool_calls.iter() {
|
||||||
@ -359,12 +360,19 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.role == "assistant"
|
if m.role == "assistant"
|
||||||
&& m.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
|
&& m.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |calls| !calls.is_empty())
|
||||||
{
|
{
|
||||||
let already_marked = remove_indices.contains(&i);
|
let already_marked = remove_indices.contains(&i);
|
||||||
if !already_marked {
|
if !already_marked {
|
||||||
pending_tool_ids = m.tool_calls.as_ref().unwrap()
|
pending_tool_ids = m
|
||||||
.iter().map(|tc| tc.id.clone()).collect();
|
.tool_calls
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|tc| tc.id.clone())
|
||||||
|
.collect();
|
||||||
pending_assistant_idx = Some(i);
|
pending_assistant_idx = Some(i);
|
||||||
}
|
}
|
||||||
} else if m.role == "tool" {
|
} else if m.role == "tool" {
|
||||||
@ -511,7 +519,10 @@ pub enum OutboundEventKind {
|
|||||||
|
|
||||||
impl OutboundMessage {
|
impl OutboundMessage {
|
||||||
pub fn is_stream_delta(&self) -> bool {
|
pub fn is_stream_delta(&self) -> bool {
|
||||||
matches!(self.event_kind, OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd)
|
matches!(
|
||||||
|
self.event_kind,
|
||||||
|
OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn assistant(
|
pub fn assistant(
|
||||||
@ -548,7 +559,8 @@ impl OutboundMessage {
|
|||||||
reply_to: Option<String>,
|
reply_to: Option<String>,
|
||||||
metadata: HashMap<String, String>,
|
metadata: HashMap<String, String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
|
let mut message =
|
||||||
|
Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
|
||||||
message.event_kind = OutboundEventKind::SchedulerNotification;
|
message.event_kind = OutboundEventKind::SchedulerNotification;
|
||||||
message
|
message
|
||||||
}
|
}
|
||||||
@ -561,7 +573,8 @@ impl OutboundMessage {
|
|||||||
reply_to: Option<String>,
|
reply_to: Option<String>,
|
||||||
metadata: HashMap<String, String>,
|
metadata: HashMap<String, String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
|
let mut message =
|
||||||
|
Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
|
||||||
message.event_kind = OutboundEventKind::ErrorNotification;
|
message.event_kind = OutboundEventKind::ErrorNotification;
|
||||||
message
|
message
|
||||||
}
|
}
|
||||||
@ -749,7 +762,8 @@ impl OutboundMessage {
|
|||||||
"assistant" => {
|
"assistant" => {
|
||||||
if let Some(tool_calls) = &message.tool_calls {
|
if let Some(tool_calls) = &message.tool_calls {
|
||||||
let mut outbound = Vec::new();
|
let mut outbound = Vec::new();
|
||||||
let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some();
|
let has_content_or_reasoning =
|
||||||
|
!message.content.trim().is_empty() || message.reasoning_content.is_some();
|
||||||
if has_content_or_reasoning {
|
if has_content_or_reasoning {
|
||||||
let mut resp = Self::assistant(
|
let mut resp = Self::assistant(
|
||||||
channel.to_string(),
|
channel.to_string(),
|
||||||
@ -766,7 +780,11 @@ impl OutboundMessage {
|
|||||||
|
|
||||||
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复;
|
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复;
|
||||||
// 只有 AssistantResponse 没发时,ToolCall 才带 reasoning
|
// 只有 AssistantResponse 没发时,ToolCall 才带 reasoning
|
||||||
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() };
|
let tc_reasoning = if has_content_or_reasoning {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
message.reasoning_content.clone()
|
||||||
|
};
|
||||||
outbound.extend(tool_calls.iter().map(|tool_call| {
|
outbound.extend(tool_calls.iter().map(|tool_call| {
|
||||||
let mut tc = Self::tool_call(
|
let mut tc = Self::tool_call(
|
||||||
channel.to_string(),
|
channel.to_string(),
|
||||||
@ -930,10 +948,7 @@ mod tests {
|
|||||||
"calculator\nargs: {\"expression\":\"1 + 1\"}"
|
"calculator\nargs: {\"expression\":\"1 + 1\"}"
|
||||||
);
|
);
|
||||||
assert_eq!(outbound[1].tool_name.as_deref(), Some("read"));
|
assert_eq!(outbound[1].tool_name.as_deref(), Some("read"));
|
||||||
assert_eq!(
|
assert_eq!(outbound[1].content, "read\nargs: {\"path\":\"README.md\"}");
|
||||||
outbound[1].content,
|
|
||||||
"read\nargs: {\"path\":\"README.md\"}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -84,7 +84,10 @@ impl Channel for CliChannel {
|
|||||||
self.shutdown_token.cancel();
|
self.shutdown_token.cancel();
|
||||||
let count = self.connections.read().await.len();
|
let count = self.connections.read().await.len();
|
||||||
self.connections.write().await.clear();
|
self.connections.write().await.clear();
|
||||||
tracing::info!(connection_count = count, "CliChannel stopped, all connections signaled to close");
|
tracing::info!(
|
||||||
|
connection_count = count,
|
||||||
|
"CliChannel stopped, all connections signaled to close"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,8 @@ use regex::Regex;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::{RwLock, broadcast};
|
use tokio::sync::{RwLock, broadcast};
|
||||||
|
|
||||||
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
|
|
||||||
use crate::bus::message::OutboundEventKind;
|
use crate::bus::message::OutboundEventKind;
|
||||||
|
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
|
||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
|
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
|
||||||
use crate::text::{char_count, truncate_with_ellipsis};
|
use crate::text::{char_count, truncate_with_ellipsis};
|
||||||
@ -548,9 +548,10 @@ impl FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.map_err(|e| {
|
let body = resp
|
||||||
ChannelError::Other(format!("Read upload image response error: {}", e))
|
.text()
|
||||||
})?;
|
.await
|
||||||
|
.map_err(|e| ChannelError::Other(format!("Read upload image response error: {}", e)))?;
|
||||||
let result: UploadResp = serde_json::from_str(&body).map_err(|e| {
|
let result: UploadResp = serde_json::from_str(&body).map_err(|e| {
|
||||||
ChannelError::Other(format!(
|
ChannelError::Other(format!(
|
||||||
"Parse upload image response error: {} (status={}, body={})",
|
"Parse upload image response error: {} (status={}, body={})",
|
||||||
@ -631,9 +632,10 @@ impl FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.map_err(|e| {
|
let body = resp
|
||||||
ChannelError::Other(format!("Read upload file response error: {}", e))
|
.text()
|
||||||
})?;
|
.await
|
||||||
|
.map_err(|e| ChannelError::Other(format!("Read upload file response error: {}", e)))?;
|
||||||
let result: UploadResp = serde_json::from_str(&body).map_err(|e| {
|
let result: UploadResp = serde_json::from_str(&body).map_err(|e| {
|
||||||
ChannelError::Other(format!(
|
ChannelError::Other(format!(
|
||||||
"Parse upload file response error: {} (status={}, body={})",
|
"Parse upload file response error: {} (status={}, body={})",
|
||||||
@ -982,9 +984,11 @@ impl FeishuChannel {
|
|||||||
reply_to: Option<&str>,
|
reply_to: Option<&str>,
|
||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
if let Some(parent_id) = reply_to {
|
if let Some(parent_id) = reply_to {
|
||||||
self.reply_to_feishu_message(parent_id, msg_type, content).await
|
self.reply_to_feishu_message(parent_id, msg_type, content)
|
||||||
|
.await
|
||||||
} else {
|
} else {
|
||||||
self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content).await
|
self.send_message_to_feishu(receive_id, receive_id_type, msg_type, content)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1440,7 +1444,8 @@ fn parse_post_content(content: &str) -> String {
|
|||||||
}
|
}
|
||||||
"code_block" => {
|
"code_block" => {
|
||||||
let lang = el.get("language").and_then(|l| l.as_str()).unwrap_or("");
|
let lang = el.get("language").and_then(|l| l.as_str()).unwrap_or("");
|
||||||
let code_text = if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) {
|
let code_text =
|
||||||
|
if let Some(content_arr) = el.get("content").and_then(|c| c.as_array()) {
|
||||||
content_arr
|
content_arr
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
|
.filter_map(|item| item.get("text").and_then(|t| t.as_str()))
|
||||||
@ -1448,7 +1453,10 @@ fn parse_post_content(content: &str) -> String {
|
|||||||
.join("")
|
.join("")
|
||||||
} else {
|
} else {
|
||||||
// Fallback to text field for backwards compatibility
|
// Fallback to text field for backwards compatibility
|
||||||
el.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string()
|
el.get("text")
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
};
|
};
|
||||||
out.push(format!("\n```{}\n{}\n```\n", lang, code_text));
|
out.push(format!("\n```{}\n{}\n```\n", lang, code_text));
|
||||||
}
|
}
|
||||||
@ -2380,7 +2388,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_post_content_handles_empty_code_block() {
|
fn parse_post_content_handles_empty_code_block() {
|
||||||
// Test code_block with empty content
|
// Test code_block with empty content
|
||||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
|
let post_json =
|
||||||
|
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
|
||||||
let result = parse_post_content(post_json);
|
let result = parse_post_content(post_json);
|
||||||
assert!(result.contains("```go"));
|
assert!(result.contains("```go"));
|
||||||
}
|
}
|
||||||
@ -2461,8 +2470,18 @@ impl Channel for FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
|
||||||
if matches!(msg.event_kind, OutboundEventKind::ToolResult | OutboundEventKind::ToolPending | OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd | OutboundEventKind::ExecutionCompleted)
|
if matches!(
|
||||||
|| msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
|
msg.event_kind,
|
||||||
|
OutboundEventKind::ToolResult
|
||||||
|
| OutboundEventKind::ToolPending
|
||||||
|
| OutboundEventKind::StreamDelta
|
||||||
|
| OutboundEventKind::StreamEnd
|
||||||
|
| OutboundEventKind::ExecutionCompleted
|
||||||
|
) || msg
|
||||||
|
.metadata
|
||||||
|
.get("is_subagent_event")
|
||||||
|
.map(|v| v == "true")
|
||||||
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@ -2553,7 +2572,13 @@ impl Channel for FeishuChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !msg.content.trim().is_empty() {
|
if !msg.content.trim().is_empty() {
|
||||||
self.dispatch_send(receive_id, receive_id_type, "text", msg.content.trim(), reply_to)
|
self.dispatch_send(
|
||||||
|
receive_id,
|
||||||
|
receive_id_type,
|
||||||
|
"text",
|
||||||
|
msg.content.trim(),
|
||||||
|
reply_to,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,9 @@ impl ChannelManager {
|
|||||||
) -> Result<(), ChannelError> {
|
) -> Result<(), ChannelError> {
|
||||||
for (name, channel_config) in &config.channels {
|
for (name, channel_config) in &config.channels {
|
||||||
match channel_config {
|
match channel_config {
|
||||||
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Feishu(feishu_config))
|
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Feishu(
|
||||||
|
feishu_config,
|
||||||
|
))
|
||||||
| crate::config::ChannelConfig::LegacyFeishu(feishu_config) => {
|
| crate::config::ChannelConfig::LegacyFeishu(feishu_config) => {
|
||||||
if feishu_config.enabled {
|
if feishu_config.enabled {
|
||||||
let channel = FeishuChannel::new(
|
let channel = FeishuChannel::new(
|
||||||
@ -72,7 +74,9 @@ impl ChannelManager {
|
|||||||
tracing::info!(channel = %name, kind = channel_config.kind(), "Channel disabled in config");
|
tracing::info!(channel = %name, kind = channel_config.kind(), "Channel disabled in config");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Wechat(wechat_config)) => {
|
crate::config::ChannelConfig::Tagged(TaggedChannelConfig::Wechat(
|
||||||
|
wechat_config,
|
||||||
|
)) => {
|
||||||
if wechat_config.enabled {
|
if wechat_config.enabled {
|
||||||
let channel = WechatChannel::new(
|
let channel = WechatChannel::new(
|
||||||
name.clone(),
|
name.clone(),
|
||||||
@ -253,8 +257,14 @@ mod tests {
|
|||||||
names.sort();
|
names.sort();
|
||||||
|
|
||||||
assert_eq!(names, vec!["backup", "primary", "websocket"]);
|
assert_eq!(names, vec!["backup", "primary", "websocket"]);
|
||||||
assert_eq!(manager.get_channel("primary").await.unwrap().name(), "primary");
|
assert_eq!(
|
||||||
assert_eq!(manager.get_channel("backup").await.unwrap().name(), "backup");
|
manager.get_channel("primary").await.unwrap().name(),
|
||||||
|
"primary"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manager.get_channel("backup").await.unwrap().name(),
|
||||||
|
"backup"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -295,7 +305,8 @@ mod tests {
|
|||||||
"cred_path": "<CRED_PATH>"
|
"cred_path": "<CRED_PATH>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"#.replace("<CRED_PATH>", &cred_path_json),
|
}"#
|
||||||
|
.replace("<CRED_PATH>", &cred_path_json),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -314,6 +325,9 @@ mod tests {
|
|||||||
names.sort();
|
names.sort();
|
||||||
|
|
||||||
assert_eq!(names, vec!["websocket", "wechat_main"]);
|
assert_eq!(names, vec!["websocket", "wechat_main"]);
|
||||||
assert_eq!(manager.get_channel("wechat_main").await.unwrap().name(), "wechat_main");
|
assert_eq!(
|
||||||
|
manager.get_channel("wechat_main").await.unwrap().name(),
|
||||||
|
"wechat_main"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,8 +13,8 @@ use tokio::sync::RwLock;
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use wechatbot::{BotOptions, SendContent, WeChatBot};
|
use wechatbot::{BotOptions, SendContent, WeChatBot};
|
||||||
|
|
||||||
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
|
|
||||||
use crate::bus::message::OutboundEventKind;
|
use crate::bus::message::OutboundEventKind;
|
||||||
|
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
|
||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::config::{LLMProviderConfig, WechatChannelConfig};
|
use crate::config::{LLMProviderConfig, WechatChannelConfig};
|
||||||
|
|
||||||
@ -55,7 +55,10 @@ impl WechatChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sender_allowed(&self, sender_id: &str) -> bool {
|
fn sender_allowed(&self, sender_id: &str) -> bool {
|
||||||
self.config.allow_from.iter().any(|pattern| pattern == "*" || pattern == sender_id)
|
self.config
|
||||||
|
.allow_from
|
||||||
|
.iter()
|
||||||
|
.any(|pattern| pattern == "*" || pattern == sender_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn media_to_send_content(
|
fn media_to_send_content(
|
||||||
@ -132,14 +135,17 @@ impl WechatChannel {
|
|||||||
) -> Result<Vec<MediaItem>, ChannelError> {
|
) -> Result<Vec<MediaItem>, ChannelError> {
|
||||||
let Some(downloaded) = bot.download(&msg).await.map_err(|error| {
|
let Some(downloaded) = bot.download(&msg).await.map_err(|error| {
|
||||||
ChannelError::Other(format!("WeChat media download failed: {}", error))
|
ChannelError::Other(format!("WeChat media download failed: {}", error))
|
||||||
})? else {
|
})?
|
||||||
|
else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
};
|
};
|
||||||
|
|
||||||
let media_dir = Self::default_media_dir();
|
let media_dir = Self::default_media_dir();
|
||||||
tokio::fs::create_dir_all(&media_dir)
|
tokio::fs::create_dir_all(&media_dir)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ChannelError::Other(format!("Failed to create WeChat media dir: {}", error)))?;
|
.map_err(|error| {
|
||||||
|
ChannelError::Other(format!("Failed to create WeChat media dir: {}", error))
|
||||||
|
})?;
|
||||||
|
|
||||||
let filename = Self::build_download_filename(
|
let filename = Self::build_download_filename(
|
||||||
&downloaded.media_type,
|
&downloaded.media_type,
|
||||||
@ -149,7 +155,9 @@ impl WechatChannel {
|
|||||||
let file_path = media_dir.join(&filename);
|
let file_path = media_dir.join(&filename);
|
||||||
tokio::fs::write(&file_path, downloaded.data)
|
tokio::fs::write(&file_path, downloaded.data)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ChannelError::Other(format!("Failed to write WeChat media file: {}", error)))?;
|
.map_err(|error| {
|
||||||
|
ChannelError::Other(format!("Failed to write WeChat media file: {}", error))
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!(filename = %filename, media_type = %downloaded.media_type, "Downloaded WeChat media");
|
tracing::info!(filename = %filename, media_type = %downloaded.media_type, "Downloaded WeChat media");
|
||||||
|
|
||||||
@ -316,7 +324,11 @@ impl Channel for WechatChannel {
|
|||||||
| OutboundEventKind::StreamDelta
|
| OutboundEventKind::StreamDelta
|
||||||
| OutboundEventKind::StreamEnd
|
| OutboundEventKind::StreamEnd
|
||||||
| OutboundEventKind::ExecutionCompleted
|
| OutboundEventKind::ExecutionCompleted
|
||||||
) || msg.metadata.get("is_subagent_event").map(|v| v == "true").unwrap_or(false)
|
) || msg
|
||||||
|
.metadata
|
||||||
|
.get("is_subagent_event")
|
||||||
|
.map(|v| v == "true")
|
||||||
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@ -344,7 +356,10 @@ impl Channel for WechatChannel {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
let content = Self::media_to_send_content(media, caption)?;
|
let content = Self::media_to_send_content(media, caption)?;
|
||||||
self.bot.send_media(&msg.chat_id, content).await.map_err(|error| {
|
self.bot
|
||||||
|
.send_media(&msg.chat_id, content)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
ChannelError::SendError(format!("WeChat media send failed: {}", error))
|
ChannelError::SendError(format!("WeChat media send failed: {}", error))
|
||||||
})?;
|
})?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@ -409,13 +424,12 @@ mod tests {
|
|||||||
std::fs::rename(file.path(), &doc_path).unwrap();
|
std::fs::rename(file.path(), &doc_path).unwrap();
|
||||||
|
|
||||||
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
|
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
|
||||||
let content = WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap();
|
let content =
|
||||||
|
WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap();
|
||||||
|
|
||||||
match content {
|
match content {
|
||||||
SendContent::File {
|
SendContent::File {
|
||||||
file_name,
|
file_name, caption, ..
|
||||||
caption,
|
|
||||||
..
|
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(file_name, doc_path.file_name().unwrap().to_string_lossy());
|
assert_eq!(file_name, doc_path.file_name().unwrap().to_string_lossy());
|
||||||
assert_eq!(caption.as_deref(), Some("note"));
|
assert_eq!(caption.as_deref(), Some("note"));
|
||||||
|
|||||||
@ -117,7 +117,11 @@ impl InitWizard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let input = line.trim().to_string();
|
let input = line.trim().to_string();
|
||||||
Ok(if input.is_empty() { default.to_string() } else { input })
|
Ok(if input.is_empty() {
|
||||||
|
default.to_string()
|
||||||
|
} else {
|
||||||
|
input
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prompt_required(&mut self, label: &str) -> Result<String, InitError> {
|
async fn prompt_required(&mut self, label: &str) -> Result<String, InitError> {
|
||||||
@ -154,9 +158,9 @@ impl InitWizard {
|
|||||||
let default_str = (default + 1).to_string();
|
let default_str = (default + 1).to_string();
|
||||||
let input = self.prompt_with_default(label, &default_str).await?;
|
let input = self.prompt_with_default(label, &default_str).await?;
|
||||||
|
|
||||||
let selected: usize = input.parse().map_err(|_| {
|
let selected: usize = input
|
||||||
InitError::InputError(format!("Invalid selection: {}", input))
|
.parse()
|
||||||
})?;
|
.map_err(|_| InitError::InputError(format!("Invalid selection: {}", input)))?;
|
||||||
|
|
||||||
if selected == 0 || selected > options.len() {
|
if selected == 0 || selected > options.len() {
|
||||||
return Err(InitError::InputError(format!(
|
return Err(InitError::InputError(format!(
|
||||||
@ -195,9 +199,7 @@ impl InitWizard {
|
|||||||
println!(" 4. Skip");
|
println!(" 4. Skip");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let choice = self
|
let choice = self.prompt_with_default("Select option", "1").await?;
|
||||||
.prompt_with_default("Select option", "1")
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match choice.as_str() {
|
match choice.as_str() {
|
||||||
"1" => return self.add_provider(existing).await,
|
"1" => return self.add_provider(existing).await,
|
||||||
@ -243,9 +245,7 @@ impl InitWizard {
|
|||||||
&mut self,
|
&mut self,
|
||||||
existing: &Config,
|
existing: &Config,
|
||||||
) -> Result<HashMap<String, ProviderConfig>, InitError> {
|
) -> Result<HashMap<String, ProviderConfig>, InitError> {
|
||||||
let provider_name = self
|
let provider_name = self.prompt_with_default("Provider name", "default").await?;
|
||||||
.prompt_with_default("Provider name", "default")
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
println!("Provider type:");
|
println!("Provider type:");
|
||||||
println!(" 1. openai");
|
println!(" 1. openai");
|
||||||
@ -307,7 +307,9 @@ impl InitWizard {
|
|||||||
};
|
};
|
||||||
let type_options = vec!["openai".to_string(), "anthropic".to_string()];
|
let type_options = vec!["openai".to_string(), "anthropic".to_string()];
|
||||||
println!("Provider type:");
|
println!("Provider type:");
|
||||||
let type_idx = self.prompt_select("", &type_options, current_type_idx).await?;
|
let type_idx = self
|
||||||
|
.prompt_select("", &type_options, current_type_idx)
|
||||||
|
.await?;
|
||||||
let provider_type = &type_options[type_idx];
|
let provider_type = &type_options[type_idx];
|
||||||
|
|
||||||
let base_url = self
|
let base_url = self
|
||||||
@ -536,9 +538,7 @@ impl InitWizard {
|
|||||||
providers: &HashMap<String, ProviderConfig>,
|
providers: &HashMap<String, ProviderConfig>,
|
||||||
models: &HashMap<String, ModelConfig>,
|
models: &HashMap<String, ModelConfig>,
|
||||||
) -> Result<HashMap<String, AgentConfig>, InitError> {
|
) -> Result<HashMap<String, AgentConfig>, InitError> {
|
||||||
let agent_name = self
|
let agent_name = self.prompt_with_default("Agent name", "default").await?;
|
||||||
.prompt_with_default("Agent name", "default")
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Select provider
|
// Select provider
|
||||||
let provider_names: Vec<String> = providers.keys().cloned().collect();
|
let provider_names: Vec<String> = providers.keys().cloned().collect();
|
||||||
@ -600,7 +600,9 @@ impl InitWizard {
|
|||||||
.position(|p| p == ¤t_agent.provider)
|
.position(|p| p == ¤t_agent.provider)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
println!("Select provider:");
|
println!("Select provider:");
|
||||||
let provider_idx = self.prompt_select("", &provider_names, current_provider_idx).await?;
|
let provider_idx = self
|
||||||
|
.prompt_select("", &provider_names, current_provider_idx)
|
||||||
|
.await?;
|
||||||
let selected_provider = &provider_names[provider_idx];
|
let selected_provider = &provider_names[provider_idx];
|
||||||
|
|
||||||
// Select new model
|
// Select new model
|
||||||
@ -611,7 +613,9 @@ impl InitWizard {
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
println!();
|
println!();
|
||||||
println!("Select model:");
|
println!("Select model:");
|
||||||
let model_idx = self.prompt_select("", &model_names, current_model_idx).await?;
|
let model_idx = self
|
||||||
|
.prompt_select("", &model_names, current_model_idx)
|
||||||
|
.await?;
|
||||||
let selected_model = &model_names[model_idx];
|
let selected_model = &model_names[model_idx];
|
||||||
|
|
||||||
let agent = AgentConfig {
|
let agent = AgentConfig {
|
||||||
@ -650,7 +654,11 @@ impl InitWizard {
|
|||||||
if !existing.channels.is_empty() {
|
if !existing.channels.is_empty() {
|
||||||
println!("Existing channels:");
|
println!("Existing channels:");
|
||||||
for (name, config) in &existing.channels {
|
for (name, config) in &existing.channels {
|
||||||
let status = if config.enabled() { "enabled" } else { "disabled" };
|
let status = if config.enabled() {
|
||||||
|
"enabled"
|
||||||
|
} else {
|
||||||
|
"disabled"
|
||||||
|
};
|
||||||
println!(" - {} ({})", name, status);
|
println!(" - {} ({})", name, status);
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
@ -700,9 +708,7 @@ impl InitWizard {
|
|||||||
println!("Configuring Feishu channel...");
|
println!("Configuring Feishu channel...");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
let channel_name = self
|
let channel_name = self.prompt_with_default("Channel name", "feishu").await?;
|
||||||
.prompt_with_default("Channel name", "feishu")
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu());
|
let _existing_config = existing.get(&channel_name).and_then(|c| c.as_feishu());
|
||||||
|
|
||||||
@ -766,7 +772,8 @@ impl InitWizard {
|
|||||||
println!();
|
println!();
|
||||||
println!("Starting WeChat login...");
|
println!("Starting WeChat login...");
|
||||||
|
|
||||||
self.do_wechat_login(base_url, &Self::default_wechat_cred_path()).await?;
|
self.do_wechat_login(base_url, &Self::default_wechat_cred_path())
|
||||||
|
.await?;
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!("WeChat login successful! Credentials saved.");
|
println!("WeChat login successful! Credentials saved.");
|
||||||
@ -796,9 +803,10 @@ impl InitWizard {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
let creds = bot.login(true).await.map_err(|e| {
|
let creds = bot
|
||||||
InitError::WeChatError(format!("WeChat login failed: {}", e))
|
.login(true)
|
||||||
})?;
|
.await
|
||||||
|
.map_err(|e| InitError::WeChatError(format!("WeChat login failed: {}", e)))?;
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
pub mod channel;
|
pub mod channel;
|
||||||
pub mod input;
|
|
||||||
pub mod init;
|
pub mod init;
|
||||||
|
pub mod input;
|
||||||
|
|
||||||
pub use channel::CliChannel;
|
pub use channel::CliChannel;
|
||||||
pub use input::{InputCommand, InputEvent, InputHandler};
|
|
||||||
pub use init::InitWizard;
|
pub use init::InitWizard;
|
||||||
|
pub use input::{InputCommand, InputEvent, InputHandler};
|
||||||
|
|||||||
@ -26,8 +26,11 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut current_session_id: Option<String> = None;
|
let mut current_session_id: Option<String> = None;
|
||||||
// Track message IDs that were already streamed so we can skip
|
// Track message IDs that were already streamed so we can skip
|
||||||
// the duplicate AssistantResponse that arrives afterwards.
|
// the duplicate AssistantResponse that arrives afterwards.
|
||||||
let mut streamed_message_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
let mut streamed_message_ids: std::collections::HashSet<String> =
|
||||||
input.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n").await?;
|
std::collections::HashSet::new();
|
||||||
|
input
|
||||||
|
.write_output("picobot CLI - Commands: /new [title], /save [filepath], /quit\n")
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Main loop: poll both stdin and WebSocket
|
// Main loop: poll both stdin and WebSocket
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::AdapterContext;
|
use crate::command::context::AdapterContext;
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
|
||||||
|
|
||||||
/// 输入适配器:将渠道特定输入转换为 Command
|
/// 输入适配器:将渠道特定输入转换为 Command
|
||||||
///
|
///
|
||||||
@ -13,11 +13,7 @@ pub trait InputAdapter: Send + Sync {
|
|||||||
/// - `Ok(Some(Command))`:成功解析为命令
|
/// - `Ok(Some(Command))`:成功解析为命令
|
||||||
/// - `Ok(None)`:不是命令(如普通聊天消息)
|
/// - `Ok(None)`:不是命令(如普通聊天消息)
|
||||||
/// - `Err(CommandError)`:解析错误(如缺少参数)
|
/// - `Err(CommandError)`:解析错误(如缺少参数)
|
||||||
fn try_parse(
|
fn try_parse(&self, input: &str, ctx: AdapterContext) -> Result<Option<Command>, AdapterError>;
|
||||||
&self,
|
|
||||||
input: &str,
|
|
||||||
ctx: AdapterContext,
|
|
||||||
) -> Result<Option<Command>, AdapterError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 输出适配器:将 CommandResponse 转换为渠道特定输出
|
/// 输出适配器:将 CommandResponse 转换为渠道特定输出
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::adapter::{AdapterError, InputAdapter};
|
use crate::command::adapter::{AdapterError, InputAdapter};
|
||||||
use crate::command::context::AdapterContext;
|
use crate::command::context::AdapterContext;
|
||||||
use crate::command::Command;
|
|
||||||
|
|
||||||
/// Channel 输入适配器
|
/// Channel 输入适配器
|
||||||
///
|
///
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
|
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
|
||||||
use crate::command::context::AdapterContext;
|
use crate::command::context::AdapterContext;
|
||||||
use crate::command::response::{CommandResponse, MessageKind};
|
use crate::command::response::{CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
|
|
||||||
/// CLI 输入适配器
|
/// CLI 输入适配器
|
||||||
///
|
///
|
||||||
@ -313,7 +313,14 @@ mod tests {
|
|||||||
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let cmd = result.unwrap();
|
let cmd = result.unwrap();
|
||||||
assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: false, .. }));
|
assert!(matches!(
|
||||||
|
cmd,
|
||||||
|
Command::SaveSession {
|
||||||
|
filepath: None,
|
||||||
|
include_all: false,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -321,7 +328,9 @@ mod tests {
|
|||||||
let adapter = CliInputAdapter::new();
|
let adapter = CliInputAdapter::new();
|
||||||
let ctx = AdapterContext::new("test");
|
let ctx = AdapterContext::new("test");
|
||||||
|
|
||||||
let result = adapter.try_parse("/save-session ./debug/session.md", ctx).unwrap();
|
let result = adapter
|
||||||
|
.try_parse("/save-session ./debug/session.md", ctx)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let cmd = result.unwrap();
|
let cmd = result.unwrap();
|
||||||
@ -344,7 +353,14 @@ mod tests {
|
|||||||
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let cmd = result.unwrap();
|
let cmd = result.unwrap();
|
||||||
assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: true, .. }));
|
assert!(matches!(
|
||||||
|
cmd,
|
||||||
|
Command::SaveSession {
|
||||||
|
filepath: None,
|
||||||
|
include_all: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -352,7 +368,9 @@ mod tests {
|
|||||||
let adapter = CliInputAdapter::new();
|
let adapter = CliInputAdapter::new();
|
||||||
let ctx = AdapterContext::new("test");
|
let ctx = AdapterContext::new("test");
|
||||||
|
|
||||||
let result = adapter.try_parse("/save-session all ./debug/session.md", ctx).unwrap();
|
let result = adapter
|
||||||
|
.try_parse("/save-session all ./debug/session.md", ctx)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let cmd = result.unwrap();
|
let cmd = result.unwrap();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
|
use crate::command::adapter::{AdapterError, InputAdapter, OutputAdapter};
|
||||||
use crate::command::context::AdapterContext;
|
use crate::command::context::AdapterContext;
|
||||||
use crate::command::response::{CommandResponse, MessageKind};
|
use crate::command::response::{CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::protocol::WsOutbound;
|
use crate::protocol::WsOutbound;
|
||||||
|
|
||||||
/// WebSocket 输入适配器
|
/// WebSocket 输入适配器
|
||||||
@ -79,8 +79,12 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
attachments: Vec::new(),
|
||||||
reasoning_content: None, user_message_id: None,
|
subagent_task_id: None,
|
||||||
|
topic_id: None,
|
||||||
|
timestamp: Some(crate::protocol::now_timestamp()),
|
||||||
|
reasoning_content: None,
|
||||||
|
user_message_id: None,
|
||||||
},
|
},
|
||||||
MessageKind::Notification => {
|
MessageKind::Notification => {
|
||||||
// 根据元数据判断具体类型
|
// 根据元数据判断具体类型
|
||||||
@ -90,9 +94,13 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
response.metadata.get("topic_id"),
|
response.metadata.get("topic_id"),
|
||||||
response.metadata.get("title"),
|
response.metadata.get("title"),
|
||||||
) {
|
) {
|
||||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
|
||||||
|
topics_json,
|
||||||
|
) {
|
||||||
Ok(topics) => {
|
Ok(topics) => {
|
||||||
let session_id = response.metadata.get("session_id")
|
let session_id = response
|
||||||
|
.metadata
|
||||||
|
.get("session_id")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
WsOutbound::TopicRenamed {
|
WsOutbound::TopicRenamed {
|
||||||
@ -106,28 +114,37 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
attachments: Vec::new(),
|
||||||
reasoning_content: None, user_message_id: None,
|
subagent_task_id: None,
|
||||||
|
topic_id: None,
|
||||||
|
timestamp: Some(crate::protocol::now_timestamp()),
|
||||||
|
reasoning_content: None,
|
||||||
|
user_message_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(topics_json) = response.metadata.get("topics") {
|
} else if let Some(topics_json) = response.metadata.get("topics") {
|
||||||
// Topic 列表响应 - 优先检查 topics
|
// Topic 列表响应 - 优先检查 topics
|
||||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
|
||||||
|
topics_json,
|
||||||
|
) {
|
||||||
Ok(topics) => {
|
Ok(topics) => {
|
||||||
let session_id = response.metadata.get("session_id")
|
let session_id = response
|
||||||
|
.metadata
|
||||||
|
.get("session_id")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
WsOutbound::TopicList {
|
WsOutbound::TopicList { topics, session_id }
|
||||||
topics,
|
|
||||||
session_id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(_) => WsOutbound::AssistantResponse {
|
Err(_) => WsOutbound::AssistantResponse {
|
||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
attachments: Vec::new(),
|
||||||
reasoning_content: None, user_message_id: None,
|
subagent_task_id: None,
|
||||||
|
topic_id: None,
|
||||||
|
timestamp: Some(crate::protocol::now_timestamp()),
|
||||||
|
reasoning_content: None,
|
||||||
|
user_message_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(session_id) = response.metadata.get("session_id") {
|
} else if let Some(session_id) = response.metadata.get("session_id") {
|
||||||
@ -139,7 +156,9 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 加载会话
|
// 加载会话
|
||||||
let message_count = response.metadata.get("message_count")
|
let message_count = response
|
||||||
|
.metadata
|
||||||
|
.get("message_count")
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
WsOutbound::SessionLoaded {
|
WsOutbound::SessionLoaded {
|
||||||
@ -150,7 +169,9 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
}
|
}
|
||||||
} else if let Some(topic_id) = response.metadata.get("topic_id") {
|
} else if let Some(topic_id) = response.metadata.get("topic_id") {
|
||||||
// 只有 topic_id,可能是加载话题
|
// 只有 topic_id,可能是加载话题
|
||||||
let message_count = response.metadata.get("message_count")
|
let message_count = response
|
||||||
|
.metadata
|
||||||
|
.get("message_count")
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
WsOutbound::SessionLoaded {
|
WsOutbound::SessionLoaded {
|
||||||
@ -166,13 +187,19 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
attachments: Vec::new(),
|
||||||
reasoning_content: None, user_message_id: None,
|
subagent_task_id: None,
|
||||||
|
topic_id: None,
|
||||||
|
timestamp: Some(crate::protocol::now_timestamp()),
|
||||||
|
reasoning_content: None,
|
||||||
|
user_message_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(sessions_json) = response.metadata.get("sessions") {
|
} else if let Some(sessions_json) = response.metadata.get("sessions") {
|
||||||
// 会话列表响应
|
// 会话列表响应
|
||||||
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(sessions_json) {
|
match serde_json::from_str::<Vec<crate::protocol::SessionSummary>>(
|
||||||
|
sessions_json,
|
||||||
|
) {
|
||||||
Ok(sessions) => {
|
Ok(sessions) => {
|
||||||
let channel_name = response.metadata.get("channel_name").cloned();
|
let channel_name = response.metadata.get("channel_name").cloned();
|
||||||
WsOutbound::SessionList {
|
WsOutbound::SessionList {
|
||||||
@ -185,28 +212,37 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
attachments: Vec::new(),
|
||||||
reasoning_content: None, user_message_id: None,
|
subagent_task_id: None,
|
||||||
|
topic_id: None,
|
||||||
|
timestamp: Some(crate::protocol::now_timestamp()),
|
||||||
|
reasoning_content: None,
|
||||||
|
user_message_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else if let Some(topics_json) = response.metadata.get("topics") {
|
} else if let Some(topics_json) = response.metadata.get("topics") {
|
||||||
// Topic 列表响应
|
// Topic 列表响应
|
||||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
|
||||||
|
topics_json,
|
||||||
|
) {
|
||||||
Ok(topics) => {
|
Ok(topics) => {
|
||||||
let session_id = response.metadata.get("session_id")
|
let session_id = response
|
||||||
|
.metadata
|
||||||
|
.get("session_id")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
WsOutbound::TopicList {
|
WsOutbound::TopicList { topics, session_id }
|
||||||
topics,
|
|
||||||
session_id,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(_) => WsOutbound::AssistantResponse {
|
Err(_) => WsOutbound::AssistantResponse {
|
||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
attachments: Vec::new(),
|
||||||
reasoning_content: None, user_message_id: None,
|
subagent_task_id: None,
|
||||||
|
topic_id: None,
|
||||||
|
timestamp: Some(crate::protocol::now_timestamp()),
|
||||||
|
reasoning_content: None,
|
||||||
|
user_message_id: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -215,8 +251,12 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
attachments: Vec::new(),
|
||||||
reasoning_content: None, user_message_id: None,
|
subagent_task_id: None,
|
||||||
|
topic_id: None,
|
||||||
|
timestamp: Some(crate::protocol::now_timestamp()),
|
||||||
|
reasoning_content: None,
|
||||||
|
user_message_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -230,8 +270,12 @@ impl OutputAdapter for WebSocketOutputAdapter {
|
|||||||
id: response.request_id.to_string(),
|
id: response.request_id.to_string(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
role: "assistant".to_string(),
|
role: "assistant".to_string(),
|
||||||
attachments: Vec::new(), subagent_task_id: None, topic_id: None, timestamp: Some(crate::protocol::now_timestamp()),
|
attachments: Vec::new(),
|
||||||
reasoning_content: None, user_message_id: None,
|
subagent_task_id: None,
|
||||||
|
topic_id: None,
|
||||||
|
timestamp: Some(crate::protocol::now_timestamp()),
|
||||||
|
reasoning_content: None,
|
||||||
|
user_message_id: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
outbounds.push(outbound);
|
outbounds.push(outbound);
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
|
use crate::agent::AgentError;
|
||||||
use crate::bus::InboundMessage;
|
use crate::bus::InboundMessage;
|
||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::agent::AgentError;
|
|
||||||
use crate::gateway::session::SessionManager;
|
use crate::gateway::session::SessionManager;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::handlers::list_topics::TopicSummary;
|
use crate::command::handlers::list_topics::TopicSummary;
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::gateway::session::SessionManager;
|
use crate::gateway::session::SessionManager;
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -100,8 +100,7 @@ async fn handle_delete_topic(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let topics_json =
|
let topics_json = serde_json::to_string(&topic_summaries)
|
||||||
serde_json::to_string(&topic_summaries)
|
|
||||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
let message = format!("✓ 已删除话题: {}", topic_title);
|
let message = format!("✓ 已删除话题: {}", topic_title);
|
||||||
@ -130,9 +129,7 @@ mod tests {
|
|||||||
|
|
||||||
// 先创建 session 和 topic
|
// 先创建 session 和 topic
|
||||||
let session = store.create_session("test_channel", Some("test")).unwrap();
|
let session = store.create_session("test_channel", Some("test")).unwrap();
|
||||||
let topic = store
|
let topic = store.create_topic(&session.id, "test topic", None).unwrap();
|
||||||
.create_topic(&session.id, "test topic", None)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let ctx = CommandContext::new("test", "test_channel")
|
let ctx = CommandContext::new("test", "test_channel")
|
||||||
.with_session_id(&session.id)
|
.with_session_id(&session.id)
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
use crate::agent::context_compressor::estimate_tokens;
|
use crate::agent::context_compressor::estimate_tokens;
|
||||||
use crate::agent::{SystemPromptContext, SystemPromptProvider};
|
use crate::agent::{SystemPromptContext, SystemPromptProvider};
|
||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -58,17 +58,23 @@ async fn handle_get_current_session(
|
|||||||
handler: &GetCurrentSessionCommandHandler,
|
handler: &GetCurrentSessionCommandHandler,
|
||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
let topic_id = ctx.topic_id.as_deref()
|
let topic_id = ctx
|
||||||
|
.topic_id
|
||||||
|
.as_deref()
|
||||||
.ok_or_else(|| CommandError::new("NO_CURRENT_TOPIC", "No current topic"))?;
|
.ok_or_else(|| CommandError::new("NO_CURRENT_TOPIC", "No current topic"))?;
|
||||||
|
|
||||||
let chat_id = ctx.chat_id.as_deref()
|
let chat_id = ctx
|
||||||
|
.chat_id
|
||||||
|
.as_deref()
|
||||||
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat id".to_string()))?;
|
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat id".to_string()))?;
|
||||||
|
|
||||||
let topic = handler
|
let topic = handler
|
||||||
.store
|
.store
|
||||||
.get_topic(topic_id)
|
.get_topic(topic_id)
|
||||||
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
|
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
|
||||||
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?;
|
.ok_or_else(|| {
|
||||||
|
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
// 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史)
|
// 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史)
|
||||||
let messages = handler
|
let messages = handler
|
||||||
@ -88,7 +94,8 @@ async fn handle_get_current_session(
|
|||||||
user_message_count,
|
user_message_count,
|
||||||
};
|
};
|
||||||
|
|
||||||
provider.build(&system_prompt_context)
|
provider
|
||||||
|
.build(&system_prompt_context)
|
||||||
.map(|sp| {
|
.map(|sp| {
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
let system_msg = ChatMessage::system(&sp.content);
|
let system_msg = ChatMessage::system(&sp.content);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@ -44,8 +44,7 @@ impl CommandHandler for HelpCommandHandler {
|
|||||||
let metadata = self.metadata.lock().unwrap();
|
let metadata = self.metadata.lock().unwrap();
|
||||||
let help_text = format_help(&metadata);
|
let help_text = format_help(&metadata);
|
||||||
|
|
||||||
Ok(CommandResponse::success(ctx.request_id)
|
Ok(CommandResponse::success(ctx.request_id).with_message(MessageKind::Text, &help_text))
|
||||||
.with_message(MessageKind::Text, &help_text))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
use crate::channels::manager::ChannelManager;
|
use crate::channels::manager::ChannelManager;
|
||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -68,7 +68,6 @@ impl CommandHandler for ListMemoriesCommandHandler {
|
|||||||
let memories_json = serde_json::to_string(&summaries)
|
let memories_json = serde_json::to_string(&summaries)
|
||||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
Ok(CommandResponse::success(ctx.request_id)
|
Ok(CommandResponse::success(ctx.request_id).with_metadata("memories", &memories_json))
|
||||||
.with_metadata("memories", &memories_json))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::protocol::{SchedulerJobSessionLookup, SchedulerJobSummary};
|
use crate::protocol::{SchedulerJobSessionLookup, SchedulerJobSummary};
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -67,8 +67,7 @@ impl CommandHandler for ListSchedulerJobsCommandHandler {
|
|||||||
let jobs_json = serde_json::to_string(&summaries)
|
let jobs_json = serde_json::to_string(&summaries)
|
||||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
Ok(CommandResponse::success(ctx.request_id)
|
Ok(CommandResponse::success(ctx.request_id).with_metadata("scheduler_jobs", &jobs_json))
|
||||||
.with_metadata("scheduler_jobs", &jobs_json))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -50,7 +50,9 @@ async fn handle_list_sessions(
|
|||||||
_include_archived: bool,
|
_include_archived: bool,
|
||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
let session_id = ctx.session_id.as_deref()
|
let session_id = ctx
|
||||||
|
.session_id
|
||||||
|
.as_deref()
|
||||||
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
|
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
|
||||||
|
|
||||||
let topics = handler
|
let topics = handler
|
||||||
@ -72,7 +74,8 @@ async fn handle_list_sessions(
|
|||||||
let marker = if is_current { " *" } else { "" };
|
let marker = if is_current { " *" } else { "" };
|
||||||
|
|
||||||
// 使用辅助方法获取消息数量
|
// 使用辅助方法获取消息数量
|
||||||
let msg_count = handler.store
|
let msg_count = handler
|
||||||
|
.store
|
||||||
.get_topic_message_count(&topic.id)
|
.get_topic_message_count(&topic.id)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::protocol::SessionSummary;
|
use crate::protocol::SessionSummary;
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::skills::SkillRuntime;
|
use crate::skills::SkillRuntime;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -58,7 +58,6 @@ impl CommandHandler for ListSkillsCommandHandler {
|
|||||||
let skills_json = serde_json::to_string(&summaries)
|
let skills_json = serde_json::to_string(&summaries)
|
||||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
Ok(CommandResponse::success(ctx.request_id)
|
Ok(CommandResponse::success(ctx.request_id).with_metadata("skills", &skills_json))
|
||||||
.with_metadata("skills", &skills_json))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::protocol::TodoItemSummary;
|
use crate::protocol::TodoItemSummary;
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -50,9 +50,7 @@ impl CommandHandler for ListTopicsCommandHandler {
|
|||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::ListTopics { session_id } => {
|
Command::ListTopics { session_id } => handle_list_topics(self, session_id, ctx).await,
|
||||||
handle_list_topics(self, session_id, ctx).await
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
/// 加载指定 channel + chat_id 的对话消息。
|
/// 加载指定 channel + chat_id 的对话消息。
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use crate::tools::task::repository::TaskRepository;
|
use crate::tools::task::repository::TaskRepository;
|
||||||
use crate::tools::task::types::{TaskSession, TaskSessionState};
|
use crate::tools::task::types::{TaskSession, TaskSessionState};
|
||||||
@ -14,11 +14,11 @@ pub struct LoadTaskMessagesCommandHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LoadTaskMessagesCommandHandler {
|
impl LoadTaskMessagesCommandHandler {
|
||||||
pub fn new(
|
pub fn new(task_repository: Arc<dyn TaskRepository>, store: Arc<SessionStore>) -> Self {
|
||||||
task_repository: Arc<dyn TaskRepository>,
|
Self {
|
||||||
store: Arc<SessionStore>,
|
task_repository,
|
||||||
) -> Self {
|
store,
|
||||||
Self { task_repository, store }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,11 +62,7 @@ async fn handle_load_task_messages(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 1. Try in-memory repository first
|
// 1. Try in-memory repository first
|
||||||
let task = match handler
|
let task = match handler.task_repository.load_task_session(&task_id).await {
|
||||||
.task_repository
|
|
||||||
.load_task_session(&task_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(task)) => {
|
Ok(Some(task)) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
task_id = %task.id,
|
task_id = %task.id,
|
||||||
@ -186,6 +182,9 @@ fn parse_subagent_title(title: &str) -> (String, String) {
|
|||||||
return (agent_type, desc);
|
return (agent_type, desc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let desc = title.strip_prefix("Subagent: ").unwrap_or(title).to_string();
|
let desc = title
|
||||||
|
.strip_prefix("Subagent: ")
|
||||||
|
.unwrap_or(title)
|
||||||
|
.to_string();
|
||||||
("general".to_string(), desc)
|
("general".to_string(), desc)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -37,9 +37,7 @@ impl CommandHandler for LoadTopicCommandHandler {
|
|||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::LoadTopic { topic_id } => {
|
Command::LoadTopic { topic_id } => handle_load_topic(self, topic_id, ctx).await,
|
||||||
handle_load_topic(self, topic_id, ctx).await
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,7 +52,9 @@ async fn handle_load_topic(
|
|||||||
.store
|
.store
|
||||||
.get_topic(&topic_id)
|
.get_topic(&topic_id)
|
||||||
.map_err(|e| CommandError::new("LOAD_TOPIC_ERROR", e.to_string()))?
|
.map_err(|e| CommandError::new("LOAD_TOPIC_ERROR", e.to_string()))?
|
||||||
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?;
|
.ok_or_else(|| {
|
||||||
|
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(CommandResponse::success(ctx.request_id)
|
Ok(CommandResponse::success(ctx.request_id)
|
||||||
.with_message(MessageKind::Notification, &topic.title)
|
.with_message(MessageKind::Notification, &topic.title)
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse};
|
use crate::command::response::{CommandError, CommandResponse};
|
||||||
use crate::command::Command;
|
use crate::storage::{GLOBAL_SCOPE_KEY, MemoryUpsert, SessionStore};
|
||||||
use crate::storage::{MemoryUpsert, SessionStore, GLOBAL_SCOPE_KEY};
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -17,10 +17,7 @@ impl MemoryCrudCommandHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 通过 ID 查找记忆的 namespace 和 memory_key
|
/// 通过 ID 查找记忆的 namespace 和 memory_key
|
||||||
fn find_by_id(
|
fn find_by_id(store: &SessionStore, id: &str) -> Result<Option<(String, String)>, CommandError> {
|
||||||
store: &SessionStore,
|
|
||||||
id: &str,
|
|
||||||
) -> Result<Option<(String, String)>, CommandError> {
|
|
||||||
let records = store
|
let records = store
|
||||||
.list_memories_for_scope("user", GLOBAL_SCOPE_KEY)
|
.list_memories_for_scope("user", GLOBAL_SCOPE_KEY)
|
||||||
.map_err(|e| CommandError::new("LIST_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("LIST_ERROR", e.to_string()))?;
|
||||||
@ -35,7 +32,9 @@ impl CommandHandler for MemoryCrudCommandHandler {
|
|||||||
fn can_handle(&self, cmd: &Command) -> bool {
|
fn can_handle(&self, cmd: &Command) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
cmd,
|
cmd,
|
||||||
Command::CreateMemory { .. } | Command::UpdateMemory { .. } | Command::DeleteMemory { .. }
|
Command::CreateMemory { .. }
|
||||||
|
| Command::UpdateMemory { .. }
|
||||||
|
| Command::DeleteMemory { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,7 +111,6 @@ impl CommandHandler for MemoryCrudCommandHandler {
|
|||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(CommandResponse::success(ctx.request_id)
|
Ok(CommandResponse::success(ctx.request_id).with_metadata("memory_updated", "true"))
|
||||||
.with_metadata("memory_updated", "true"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,15 @@ pub mod help;
|
|||||||
pub mod list_channels;
|
pub mod list_channels;
|
||||||
pub mod list_memories;
|
pub mod list_memories;
|
||||||
pub mod list_scheduler_jobs;
|
pub mod list_scheduler_jobs;
|
||||||
pub mod list_skills;
|
|
||||||
pub mod list_todos;
|
|
||||||
pub mod memory_crud;
|
|
||||||
pub mod list_sessions;
|
pub mod list_sessions;
|
||||||
pub mod list_sessions_by_channel;
|
pub mod list_sessions_by_channel;
|
||||||
|
pub mod list_skills;
|
||||||
|
pub mod list_todos;
|
||||||
pub mod list_topics;
|
pub mod list_topics;
|
||||||
pub mod load_chat_messages;
|
pub mod load_chat_messages;
|
||||||
pub mod load_task_messages;
|
pub mod load_task_messages;
|
||||||
pub mod load_topic;
|
pub mod load_topic;
|
||||||
|
pub mod memory_crud;
|
||||||
pub mod rename_topic;
|
pub mod rename_topic;
|
||||||
pub mod save_session;
|
pub mod save_session;
|
||||||
pub mod save_topic;
|
pub mod save_topic;
|
||||||
@ -22,8 +22,7 @@ pub mod switch_topic;
|
|||||||
|
|
||||||
// 导出公共函数供其他模块复用
|
// 导出公共函数供其他模块复用
|
||||||
pub use save_session::{
|
pub use save_session::{
|
||||||
escape_yaml_string, format_message_content, format_timestamp,
|
SubagentTaskData, escape_yaml_string, format_message_content, format_timestamp,
|
||||||
generate_messages_markdown, generate_system_prompt_markdown,
|
generate_messages_markdown, generate_subagent_tasks_markdown, generate_system_prompt_markdown,
|
||||||
generate_subagent_tasks_markdown, load_subagent_data, SubagentTaskData,
|
load_subagent_data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::handlers::list_topics::TopicSummary;
|
use crate::command::handlers::list_topics::TopicSummary;
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -86,7 +86,10 @@ async fn handle_rename_topic(
|
|||||||
let topic_summaries = serialize_summaries(&topics);
|
let topic_summaries = serialize_summaries(&topics);
|
||||||
|
|
||||||
return Ok(CommandResponse::success(ctx.request_id)
|
return Ok(CommandResponse::success(ctx.request_id)
|
||||||
.with_message(MessageKind::Notification, &format!("✓ 话题标题未变化: {}", trimmed_title))
|
.with_message(
|
||||||
|
MessageKind::Notification,
|
||||||
|
&format!("✓ 话题标题未变化: {}", trimmed_title),
|
||||||
|
)
|
||||||
.with_metadata("topics", &topic_summaries)
|
.with_metadata("topics", &topic_summaries)
|
||||||
.with_metadata("topic_id", &topic_id)
|
.with_metadata("topic_id", &topic_id)
|
||||||
.with_metadata("title", trimmed_title)
|
.with_metadata("title", trimmed_title)
|
||||||
@ -149,9 +152,7 @@ mod tests {
|
|||||||
let store = handler.store.clone();
|
let store = handler.store.clone();
|
||||||
|
|
||||||
let session = store.create_session("test_channel", Some("test")).unwrap();
|
let session = store.create_session("test_channel", Some("test")).unwrap();
|
||||||
let topic = store
|
let topic = store.create_topic(&session.id, "old title", None).unwrap();
|
||||||
.create_topic(&session.id, "old title", None)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let ctx = CommandContext::new("test", "test_channel")
|
let ctx = CommandContext::new("test", "test_channel")
|
||||||
.with_session_id(&session.id)
|
.with_session_id(&session.id)
|
||||||
@ -166,8 +167,14 @@ mod tests {
|
|||||||
|
|
||||||
let resp = result.unwrap();
|
let resp = result.unwrap();
|
||||||
assert!(resp.success);
|
assert!(resp.success);
|
||||||
assert_eq!(resp.metadata.get("title").map(String::as_str), Some("new title"));
|
assert_eq!(
|
||||||
assert_eq!(resp.metadata.get("topic_id").map(String::as_str), Some(topic.id.as_str()));
|
resp.metadata.get("title").map(String::as_str),
|
||||||
|
Some("new title")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resp.metadata.get("topic_id").map(String::as_str),
|
||||||
|
Some(topic.id.as_str())
|
||||||
|
);
|
||||||
assert!(resp.metadata.contains_key("topics"));
|
assert!(resp.metadata.contains_key("topics"));
|
||||||
|
|
||||||
// 验证存储层已更新
|
// 验证存储层已更新
|
||||||
@ -181,9 +188,7 @@ mod tests {
|
|||||||
let store = handler.store.clone();
|
let store = handler.store.clone();
|
||||||
|
|
||||||
let session = store.create_session("test_channel", Some("test")).unwrap();
|
let session = store.create_session("test_channel", Some("test")).unwrap();
|
||||||
let topic = store
|
let topic = store.create_topic(&session.id, "old title", None).unwrap();
|
||||||
.create_topic(&session.id, "old title", None)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let ctx = CommandContext::new("test", "test_channel")
|
let ctx = CommandContext::new("test", "test_channel")
|
||||||
.with_session_id(&session.id)
|
.with_session_id(&session.id)
|
||||||
@ -229,9 +234,7 @@ mod tests {
|
|||||||
let store = handler.store.clone();
|
let store = handler.store.clone();
|
||||||
|
|
||||||
let session = store.create_session("test_channel", Some("test")).unwrap();
|
let session = store.create_session("test_channel", Some("test")).unwrap();
|
||||||
let topic = store
|
let topic = store.create_topic(&session.id, "same title", None).unwrap();
|
||||||
.create_topic(&session.id, "same title", None)
|
|
||||||
.unwrap();
|
|
||||||
let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at;
|
let original_updated_at = store.get_topic(&topic.id).unwrap().unwrap().updated_at;
|
||||||
|
|
||||||
// 等待一秒确保 updated_at 会变化(如果真的写入)
|
// 等待一秒确保 updated_at 会变化(如果真的写入)
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
|
use crate::agent::AgentError;
|
||||||
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
||||||
use crate::bus::InboundMessage;
|
use crate::bus::InboundMessage;
|
||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata, InChatCommandHandler};
|
use crate::command::handler::{CommandHandler, CommandMetadata, InChatCommandHandler};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::{SessionRecord, SessionStore};
|
use crate::storage::{SessionRecord, SessionStore};
|
||||||
use crate::tools::task::repository::TaskRepository;
|
use crate::tools::task::repository::TaskRepository;
|
||||||
use crate::agent::AgentError;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{Local, TimeZone};
|
use chrono::{Local, TimeZone};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@ -65,7 +65,8 @@ pub async fn save_session_to_file(
|
|||||||
let system_prompt = build_system_prompt(system_prompt_provider, &record, user_message_count);
|
let system_prompt = build_system_prompt(system_prompt_provider, &record, user_message_count);
|
||||||
|
|
||||||
// 生成 Markdown 内容
|
// 生成 Markdown 内容
|
||||||
let markdown = generate_markdown_with_subagents(&record, &system_prompt, &messages, &subagent_data);
|
let markdown =
|
||||||
|
generate_markdown_with_subagents(&record, &system_prompt, &messages, &subagent_data);
|
||||||
|
|
||||||
// 确定输出路径
|
// 确定输出路径
|
||||||
let output_path = resolve_filepath(filepath, &record);
|
let output_path = resolve_filepath(filepath, &record);
|
||||||
@ -79,8 +80,7 @@ pub async fn save_session_to_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
std::fs::write(&output_path, markdown)
|
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||||
.map_err(|e| format!("Failed to write file: {}", e))?;
|
|
||||||
|
|
||||||
Ok(output_path)
|
Ok(output_path)
|
||||||
}
|
}
|
||||||
@ -134,9 +134,11 @@ impl CommandHandler for SaveSessionCommandHandler {
|
|||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::SaveSession { filepath, include_all, include_subagents } => {
|
Command::SaveSession {
|
||||||
handle_save_session(self, filepath, include_all, include_subagents, ctx).await
|
filepath,
|
||||||
}
|
include_all,
|
||||||
|
include_subagents,
|
||||||
|
} => handle_save_session(self, filepath, include_all, include_subagents, ctx).await,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -199,13 +201,9 @@ async fn handle_save_session(
|
|||||||
|
|
||||||
// 根据 include_all 获取消息数量
|
// 根据 include_all 获取消息数量
|
||||||
let message_count = if include_all {
|
let message_count = if include_all {
|
||||||
handler
|
handler.store.load_all_messages(session_id)
|
||||||
.store
|
|
||||||
.load_all_messages(session_id)
|
|
||||||
} else {
|
} else {
|
||||||
handler
|
handler.store.load_messages(session_id)
|
||||||
.store
|
|
||||||
.load_messages(session_id)
|
|
||||||
}
|
}
|
||||||
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?
|
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?
|
||||||
.len();
|
.len();
|
||||||
@ -215,9 +213,15 @@ async fn handle_save_session(
|
|||||||
MessageKind::Notification,
|
MessageKind::Notification,
|
||||||
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
||||||
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
||||||
&format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/")),
|
&format!(
|
||||||
|
"Session saved to: {}",
|
||||||
|
output_path.display().to_string().replace('\\', "/")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_metadata(
|
||||||
|
"filepath",
|
||||||
|
&output_path.display().to_string().replace('\\', "/"),
|
||||||
)
|
)
|
||||||
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
|
|
||||||
.with_metadata("message_count", &message_count.to_string()))
|
.with_metadata("message_count", &message_count.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,12 +351,18 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
|
|||||||
output.push_str("# Subagent Tasks\n\n");
|
output.push_str("# Subagent Tasks\n\n");
|
||||||
|
|
||||||
for task in subagent_data {
|
for task in subagent_data {
|
||||||
output.push_str(&format!("## Task: {} ({})", task.description, task.subagent_type));
|
output.push_str(&format!(
|
||||||
|
"## Task: {} ({})",
|
||||||
|
task.description, task.subagent_type
|
||||||
|
));
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
output.push_str(&format!("**Task ID:** `{}`\n\n", task.task_id));
|
output.push_str(&format!("**Task ID:** `{}`\n\n", task.task_id));
|
||||||
output.push_str(&format!("**Session ID:** `{}`\n\n", task.session_id));
|
output.push_str(&format!("**Session ID:** `{}`\n\n", task.session_id));
|
||||||
output.push_str(&format!("**Status:** {}\n\n", task.state));
|
output.push_str(&format!("**Status:** {}\n\n", task.state));
|
||||||
output.push_str(&format!("**Created:** {}\n\n", format_timestamp(task.created_at)));
|
output.push_str(&format!(
|
||||||
|
"**Created:** {}\n\n",
|
||||||
|
format_timestamp(task.created_at)
|
||||||
|
));
|
||||||
output.push_str(&format!("**Message Count:** {}\n\n", task.messages.len()));
|
output.push_str(&format!("**Message Count:** {}\n\n", task.messages.len()));
|
||||||
|
|
||||||
// 子智能体消息
|
// 子智能体消息
|
||||||
@ -361,7 +371,10 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
|
|||||||
for (idx, msg) in task.messages.iter().enumerate() {
|
for (idx, msg) in task.messages.iter().enumerate() {
|
||||||
output.push_str(&format!("#### Message {}\n\n", idx + 1));
|
output.push_str(&format!("#### Message {}\n\n", idx + 1));
|
||||||
output.push_str(&format!("**Role:** {}\n\n", msg.role));
|
output.push_str(&format!("**Role:** {}\n\n", msg.role));
|
||||||
output.push_str(&format!("**Time:** {}\n\n", format_timestamp(msg.timestamp)));
|
output.push_str(&format!(
|
||||||
|
"**Time:** {}\n\n",
|
||||||
|
format_timestamp(msg.timestamp)
|
||||||
|
));
|
||||||
|
|
||||||
if let Some(ref reasoning) = msg.reasoning_content {
|
if let Some(ref reasoning) = msg.reasoning_content {
|
||||||
output.push_str("**Reasoning:**\n");
|
output.push_str("**Reasoning:**\n");
|
||||||
@ -676,7 +689,12 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
|
|||||||
inbound: &InboundMessage,
|
inbound: &InboundMessage,
|
||||||
session_manager: &crate::gateway::session::SessionManager,
|
session_manager: &crate::gateway::session::SessionManager,
|
||||||
) -> Result<Option<String>, AgentError> {
|
) -> Result<Option<String>, AgentError> {
|
||||||
let Command::SaveSession { filepath, include_all, include_subagents } = cmd else {
|
let Command::SaveSession {
|
||||||
|
filepath,
|
||||||
|
include_all,
|
||||||
|
include_subagents,
|
||||||
|
} = cmd
|
||||||
|
else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -707,7 +725,10 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
|
|||||||
// 返回成功或失败消息
|
// 返回成功或失败消息
|
||||||
match result {
|
match result {
|
||||||
Ok(output_path) => {
|
Ok(output_path) => {
|
||||||
let msg = format!("Session saved to: {}", output_path.display().to_string().replace('\\', "/"));
|
let msg = format!(
|
||||||
|
"Session saved to: {}",
|
||||||
|
output_path.display().to_string().replace('\\', "/")
|
||||||
|
);
|
||||||
tracing::info!("{}", msg);
|
tracing::info!("{}", msg);
|
||||||
Ok(Some(msg))
|
Ok(Some(msg))
|
||||||
}
|
}
|
||||||
@ -774,7 +795,10 @@ mod tests {
|
|||||||
fn test_escape_yaml_string() {
|
fn test_escape_yaml_string() {
|
||||||
assert_eq!(escape_yaml_string("simple"), "simple");
|
assert_eq!(escape_yaml_string("simple"), "simple");
|
||||||
assert_eq!(escape_yaml_string("with: colon"), "\"with: colon\"");
|
assert_eq!(escape_yaml_string("with: colon"), "\"with: colon\"");
|
||||||
assert_eq!(escape_yaml_string("with \"quote\""), "\"with \\\"quote\\\"\"");
|
assert_eq!(
|
||||||
|
escape_yaml_string("with \"quote\""),
|
||||||
|
"\"with \\\"quote\\\"\""
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -835,14 +859,26 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_can_handle() {
|
fn test_can_handle() {
|
||||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||||
let task_repository = Arc::new(crate::tools::task::repository::InMemoryTaskRepository::new());
|
let task_repository =
|
||||||
|
Arc::new(crate::tools::task::repository::InMemoryTaskRepository::new());
|
||||||
let provider = Arc::new(TestSystemPromptProvider);
|
let provider = Arc::new(TestSystemPromptProvider);
|
||||||
let handler = SaveSessionCommandHandler::new(store, task_repository, provider);
|
let handler = SaveSessionCommandHandler::new(store, task_repository, provider);
|
||||||
|
|
||||||
assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: false, include_subagents: false }));
|
assert!(handler.can_handle(&Command::SaveSession {
|
||||||
assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: true, include_subagents: false }));
|
filepath: None,
|
||||||
|
include_all: false,
|
||||||
|
include_subagents: false
|
||||||
|
}));
|
||||||
|
assert!(handler.can_handle(&Command::SaveSession {
|
||||||
|
filepath: None,
|
||||||
|
include_all: true,
|
||||||
|
include_subagents: false
|
||||||
|
}));
|
||||||
assert!(!handler.can_handle(&Command::CreateSession { title: None }));
|
assert!(!handler.can_handle(&Command::CreateSession { title: None }));
|
||||||
assert!(!handler.can_handle(&Command::SaveTopic { filepath: None, include_subagents: false }));
|
assert!(!handler.can_handle(&Command::SaveTopic {
|
||||||
|
filepath: None,
|
||||||
|
include_subagents: false
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试用的系统提示词提供者
|
/// 测试用的系统提示词提供者
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::handlers::{
|
use crate::command::handlers::{
|
||||||
escape_yaml_string, format_timestamp, generate_messages_markdown,
|
SubagentTaskData, escape_yaml_string, format_timestamp, generate_messages_markdown,
|
||||||
generate_subagent_tasks_markdown, generate_system_prompt_markdown,
|
generate_subagent_tasks_markdown, generate_system_prompt_markdown, load_subagent_data,
|
||||||
load_subagent_data, SubagentTaskData,
|
|
||||||
};
|
};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::storage::{SessionStore, TopicRecord};
|
use crate::storage::{SessionStore, TopicRecord};
|
||||||
use crate::tools::task::repository::TaskRepository;
|
use crate::tools::task::repository::TaskRepository;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -63,8 +62,7 @@ pub async fn save_topic_to_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
std::fs::write(&output_path, markdown)
|
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||||
.map_err(|e| format!("Failed to write file: {}", e))?;
|
|
||||||
|
|
||||||
Ok(output_path)
|
Ok(output_path)
|
||||||
}
|
}
|
||||||
@ -210,9 +208,10 @@ impl CommandHandler for SaveTopicCommandHandler {
|
|||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::SaveTopic { filepath, include_subagents } => {
|
Command::SaveTopic {
|
||||||
handle_save_topic(self, filepath, include_subagents, ctx).await
|
filepath,
|
||||||
}
|
include_subagents,
|
||||||
|
} => handle_save_topic(self, filepath, include_subagents, ctx).await,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -249,14 +248,19 @@ async fn handle_save_topic(
|
|||||||
.store
|
.store
|
||||||
.get_topic(topic_id)
|
.get_topic(topic_id)
|
||||||
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
|
.map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))?
|
||||||
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?;
|
.ok_or_else(|| {
|
||||||
|
CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id))
|
||||||
|
})?;
|
||||||
|
|
||||||
let messages = handler
|
let messages = handler
|
||||||
.store
|
.store
|
||||||
.load_messages_for_topic(topic_id, Some(&topic_record.session_id))
|
.load_messages_for_topic(topic_id, Some(&topic_record.session_id))
|
||||||
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
tracing::debug!(message_count = messages.len(), "Loaded messages from DB for topic");
|
tracing::debug!(
|
||||||
|
message_count = messages.len(),
|
||||||
|
"Loaded messages from DB for topic"
|
||||||
|
);
|
||||||
|
|
||||||
// 调用保存函数
|
// 调用保存函数
|
||||||
let output_path = save_topic_to_file(
|
let output_path = save_topic_to_file(
|
||||||
@ -278,8 +282,14 @@ async fn handle_save_topic(
|
|||||||
MessageKind::Notification,
|
MessageKind::Notification,
|
||||||
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
||||||
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
||||||
&format!("Topic saved to: {}", output_path.display().to_string().replace('\\', "/")),
|
&format!(
|
||||||
|
"Topic saved to: {}",
|
||||||
|
output_path.display().to_string().replace('\\', "/")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_metadata(
|
||||||
|
"filepath",
|
||||||
|
&output_path.display().to_string().replace('\\', "/"),
|
||||||
)
|
)
|
||||||
.with_metadata("filepath", &output_path.display().to_string().replace('\\', "/"))
|
|
||||||
.with_metadata("message_count", &message_count.to_string()))
|
.with_metadata("message_count", &message_count.to_string()))
|
||||||
}
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::handlers::list_topics::TopicSummary;
|
use crate::command::handlers::list_topics::TopicSummary;
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::gateway::session::SessionManager;
|
use crate::gateway::session::SessionManager;
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -56,7 +56,9 @@ impl CommandHandler for SessionCommandHandler {
|
|||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::CreateSession { title } => handle_create_session(self, title, ctx).await,
|
Command::CreateSession { title } => handle_create_session(self, title, ctx).await,
|
||||||
Command::SaveSession { .. } => unreachable!("SaveSession should be handled by SaveSessionCommandHandler"),
|
Command::SaveSession { .. } => {
|
||||||
|
unreachable!("SaveSession should be handled by SaveSessionCommandHandler")
|
||||||
|
}
|
||||||
_ => unreachable!("Other commands should be handled by other handlers"),
|
_ => unreachable!("Other commands should be handled by other handlers"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,13 +71,16 @@ async fn handle_create_session(
|
|||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
// 获取当前 session_id,如果没有则报错
|
// 获取当前 session_id,如果没有则报错
|
||||||
let session_id = ctx.session_id.as_deref()
|
let session_id = ctx.session_id.as_deref().ok_or_else(|| {
|
||||||
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session. Please ensure a session exists first."))?;
|
CommandError::new(
|
||||||
|
"NO_SESSION",
|
||||||
|
"No active session. Please ensure a session exists first.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// 创建新话题(在同一个 Session 内)
|
// 创建新话题(在同一个 Session 内)
|
||||||
let topic_title = title.unwrap_or_else(|| {
|
let topic_title =
|
||||||
format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8])
|
title.unwrap_or_else(|| format!("Topic {}", &uuid::Uuid::new_v4().to_string()[..8]));
|
||||||
});
|
|
||||||
|
|
||||||
let topic = handler
|
let topic = handler
|
||||||
.store
|
.store
|
||||||
@ -83,14 +88,17 @@ async fn handle_create_session(
|
|||||||
.map_err(|e| CommandError::new("CREATE_TOPIC_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("CREATE_TOPIC_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
// 获取 chat_id
|
// 获取 chat_id
|
||||||
let chat_id = ctx.chat_id.as_deref()
|
let chat_id = ctx
|
||||||
|
.chat_id
|
||||||
|
.as_deref()
|
||||||
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
|
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
|
||||||
|
|
||||||
// 如果有 SessionManager,自动切换到新话题
|
// 如果有 SessionManager,自动切换到新话题
|
||||||
if let Some(ref session_manager) = handler.session_manager {
|
if let Some(ref session_manager) = handler.session_manager {
|
||||||
if let Some(session) = session_manager.get(&ctx.channel_name).await {
|
if let Some(session) = session_manager.get(&ctx.channel_name).await {
|
||||||
let mut session_guard = session.lock().await;
|
let mut session_guard = session.lock().await;
|
||||||
session_guard.switch_topic(chat_id, &topic.id)
|
session_guard
|
||||||
|
.switch_topic(chat_id, &topic.id)
|
||||||
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::gateway::cancel_manager::CancelManager;
|
use crate::gateway::cancel_manager::CancelManager;
|
||||||
use crate::gateway::session::SessionManager;
|
use crate::gateway::session::SessionManager;
|
||||||
|
|
||||||
@ -15,7 +15,10 @@ pub struct StopExecutionCommandHandler {
|
|||||||
|
|
||||||
impl StopExecutionCommandHandler {
|
impl StopExecutionCommandHandler {
|
||||||
pub fn new(cancel_manager: CancelManager, session_manager: SessionManager) -> Self {
|
pub fn new(cancel_manager: CancelManager, session_manager: SessionManager) -> Self {
|
||||||
Self { cancel_manager, session_manager }
|
Self {
|
||||||
|
cancel_manager,
|
||||||
|
session_manager,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +56,11 @@ impl CommandHandler for StopExecutionCommandHandler {
|
|||||||
None => {
|
None => {
|
||||||
// 从 SessionManager 获取真实的 current topic
|
// 从 SessionManager 获取真实的 current topic
|
||||||
let chat_id = ctx.chat_id.as_deref().unwrap_or("");
|
let chat_id = ctx.chat_id.as_deref().unwrap_or("");
|
||||||
match self.session_manager.get_current_topic(&ctx.channel_name, chat_id).await {
|
match self
|
||||||
|
.session_manager
|
||||||
|
.get_current_topic(&ctx.channel_name, chat_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Some(id)) => {
|
Ok(Some(id)) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %ctx.channel_name,
|
channel = %ctx.channel_name,
|
||||||
@ -65,12 +72,16 @@ impl CommandHandler for StopExecutionCommandHandler {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return Ok(CommandResponse::success(ctx.request_id)
|
return Ok(CommandResponse::success(ctx.request_id).with_message(
|
||||||
.with_message(MessageKind::Notification, "当前没有活跃的话题,无法停止"));
|
MessageKind::Notification,
|
||||||
|
"当前没有活跃的话题,无法停止",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Ok(CommandResponse::error(ctx.request_id,
|
return Ok(CommandResponse::error(
|
||||||
CommandError::new("QUERY_TOPIC_ERROR", e.to_string())));
|
ctx.request_id,
|
||||||
|
CommandError::new("QUERY_TOPIC_ERROR", e.to_string()),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
|
use crate::command::Command;
|
||||||
use crate::command::context::CommandContext;
|
use crate::command::context::CommandContext;
|
||||||
use crate::command::handler::{CommandHandler, CommandMetadata};
|
use crate::command::handler::{CommandHandler, CommandMetadata};
|
||||||
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
use crate::command::response::{CommandError, CommandResponse, MessageKind};
|
||||||
use crate::command::Command;
|
|
||||||
use crate::gateway::session::SessionManager;
|
use crate::gateway::session::SessionManager;
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -15,7 +15,10 @@ pub struct SwitchTopicCommandHandler {
|
|||||||
|
|
||||||
impl SwitchTopicCommandHandler {
|
impl SwitchTopicCommandHandler {
|
||||||
pub fn new(store: Arc<SessionStore>) -> Self {
|
pub fn new(store: Arc<SessionStore>) -> Self {
|
||||||
Self { store, session_manager: None }
|
Self {
|
||||||
|
store,
|
||||||
|
session_manager: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self {
|
pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self {
|
||||||
@ -44,9 +47,7 @@ impl CommandHandler for SwitchTopicCommandHandler {
|
|||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Command::SwitchTopic { topic_id } => {
|
Command::SwitchTopic { topic_id } => handle_switch_topic(self, topic_id, ctx).await,
|
||||||
handle_switch_topic(self, topic_id, ctx).await
|
|
||||||
}
|
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -57,9 +58,13 @@ async fn handle_switch_topic(
|
|||||||
topic_id: String,
|
topic_id: String,
|
||||||
ctx: CommandContext,
|
ctx: CommandContext,
|
||||||
) -> Result<CommandResponse, CommandError> {
|
) -> Result<CommandResponse, CommandError> {
|
||||||
let session_id = ctx.session_id.as_deref()
|
let session_id = ctx
|
||||||
|
.session_id
|
||||||
|
.as_deref()
|
||||||
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
|
.ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?;
|
||||||
let chat_id = ctx.chat_id.as_deref()
|
let chat_id = ctx
|
||||||
|
.chat_id
|
||||||
|
.as_deref()
|
||||||
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
|
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
|
||||||
|
|
||||||
// 尝试解析为序号
|
// 尝试解析为序号
|
||||||
@ -73,7 +78,11 @@ async fn handle_switch_topic(
|
|||||||
if index >= topics.len() {
|
if index >= topics.len() {
|
||||||
return Err(CommandError::new(
|
return Err(CommandError::new(
|
||||||
"INVALID_TOPIC_INDEX",
|
"INVALID_TOPIC_INDEX",
|
||||||
format!("Topic index {} is out of range (1-{})", index + 1, topics.len())
|
format!(
|
||||||
|
"Topic index {} is out of range (1-{})",
|
||||||
|
index + 1,
|
||||||
|
topics.len()
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
topics[index].id.clone()
|
topics[index].id.clone()
|
||||||
@ -86,19 +95,26 @@ async fn handle_switch_topic(
|
|||||||
.store
|
.store
|
||||||
.get_topic(&target_topic_id)
|
.get_topic(&target_topic_id)
|
||||||
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?
|
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?
|
||||||
.ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", target_topic_id)))?;
|
.ok_or_else(|| {
|
||||||
|
CommandError::new(
|
||||||
|
"TOPIC_NOT_FOUND",
|
||||||
|
format!("Topic not found: {}", target_topic_id),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// 如果有 SessionManager,实际切换话题历史
|
// 如果有 SessionManager,实际切换话题历史
|
||||||
if let Some(ref session_manager) = handler.session_manager {
|
if let Some(ref session_manager) = handler.session_manager {
|
||||||
if let Some(session) = session_manager.get(&ctx.channel_name).await {
|
if let Some(session) = session_manager.get(&ctx.channel_name).await {
|
||||||
let mut session_guard = session.lock().await;
|
let mut session_guard = session.lock().await;
|
||||||
session_guard.switch_topic(chat_id, &target_topic_id)
|
session_guard
|
||||||
|
.switch_topic(chat_id, &target_topic_id)
|
||||||
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用辅助方法获取消息数量
|
// 使用辅助方法获取消息数量
|
||||||
let msg_count = handler.store
|
let msg_count = handler
|
||||||
|
.store
|
||||||
.get_topic_message_count(&target_topic_id)
|
.get_topic_message_count(&target_topic_id)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
|||||||
@ -48,10 +48,7 @@ pub enum Command {
|
|||||||
/// 列出所有定时任务
|
/// 列出所有定时任务
|
||||||
ListSchedulerJobs,
|
ListSchedulerJobs,
|
||||||
/// 加载指定 channel + chat_id 的对话消息
|
/// 加载指定 channel + chat_id 的对话消息
|
||||||
LoadChatMessages {
|
LoadChatMessages { channel: String, chat_id: String },
|
||||||
channel: String,
|
|
||||||
chat_id: String,
|
|
||||||
},
|
|
||||||
/// 删除指定话题
|
/// 删除指定话题
|
||||||
DeleteTopic { topic_id: String },
|
DeleteTopic { topic_id: String },
|
||||||
/// 重命名指定话题
|
/// 重命名指定话题
|
||||||
@ -67,10 +64,7 @@ pub enum Command {
|
|||||||
content: String,
|
content: String,
|
||||||
},
|
},
|
||||||
/// 更新已有记忆
|
/// 更新已有记忆
|
||||||
UpdateMemory {
|
UpdateMemory { id: String, content: String },
|
||||||
id: String,
|
|
||||||
content: String,
|
|
||||||
},
|
|
||||||
/// 删除记忆
|
/// 删除记忆
|
||||||
DeleteMemory { id: String },
|
DeleteMemory { id: String },
|
||||||
/// 列出所有技能
|
/// 列出所有技能
|
||||||
|
|||||||
@ -1061,7 +1061,10 @@ pub struct ModelResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ModelResolver {
|
impl ModelResolver {
|
||||||
pub fn new(providers: HashMap<String, ProviderConfig>, models: HashMap<String, ModelConfig>) -> Self {
|
pub fn new(
|
||||||
|
providers: HashMap<String, ProviderConfig>,
|
||||||
|
models: HashMap<String, ModelConfig>,
|
||||||
|
) -> Self {
|
||||||
Self { providers, models }
|
Self { providers, models }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1156,7 +1159,8 @@ fn resolve_env_placeholders(content: &str) -> String {
|
|||||||
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
||||||
});
|
});
|
||||||
|
|
||||||
re_angle.replace_all(&content, |caps: ®ex::Captures| {
|
re_angle
|
||||||
|
.replace_all(&content, |caps: ®ex::Captures| {
|
||||||
let var_name = &caps[1];
|
let var_name = &caps[1];
|
||||||
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
|
||||||
})
|
})
|
||||||
@ -1738,7 +1742,8 @@ mod tests {
|
|||||||
"allow_from": ["wxid_1"]
|
"allow_from": ["wxid_1"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"#.replace("<CRED_PATH>", &cred_path_json),
|
}"#
|
||||||
|
.replace("<CRED_PATH>", &cred_path_json),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,9 @@ static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
EXPERT_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner())
|
EXPERT_TEST_ENV_LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|err| err.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A discovered expert definition.
|
/// A discovered expert definition.
|
||||||
@ -291,13 +293,20 @@ impl ExpertRuntime {
|
|||||||
|
|
||||||
/// Re-discover experts from the filesystem.
|
/// Re-discover experts from the filesystem.
|
||||||
pub fn reload(&self) -> Result<ExpertCatalog, String> {
|
pub fn reload(&self) -> Result<ExpertCatalog, String> {
|
||||||
let config = self.config.read().expect("experts config rwlock poisoned").clone();
|
let config = self
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.expect("experts config rwlock poisoned")
|
||||||
|
.clone();
|
||||||
let catalog = ExpertCatalog::discover_with_state(
|
let catalog = ExpertCatalog::discover_with_state(
|
||||||
&config,
|
&config,
|
||||||
&self.cwd,
|
&self.cwd,
|
||||||
Some(&load_expert_disable_state(&self.cwd)),
|
Some(&load_expert_disable_state(&self.cwd)),
|
||||||
);
|
);
|
||||||
let mut guard = self.catalog.write().expect("experts catalog rwlock poisoned");
|
let mut guard = self
|
||||||
|
.catalog
|
||||||
|
.write()
|
||||||
|
.expect("experts catalog rwlock poisoned");
|
||||||
*guard = catalog.clone();
|
*guard = catalog.clone();
|
||||||
Ok(catalog)
|
Ok(catalog)
|
||||||
}
|
}
|
||||||
@ -324,7 +333,11 @@ impl ExpertRuntime {
|
|||||||
|
|
||||||
/// List all discovered experts including disabled ones, with their disabled scopes.
|
/// List all discovered experts including disabled ones, with their disabled scopes.
|
||||||
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
|
pub fn list_experts_with_status(&self) -> Vec<ExpertWithStatus> {
|
||||||
let config = self.config.read().expect("experts config rwlock poisoned").clone();
|
let config = self
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.expect("experts config rwlock poisoned")
|
||||||
|
.clone();
|
||||||
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
|
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
|
||||||
let disable_state = load_expert_disable_state(&self.cwd);
|
let disable_state = load_expert_disable_state(&self.cwd);
|
||||||
|
|
||||||
@ -411,7 +424,15 @@ impl ExpertRuntime {
|
|||||||
let next_provider = provider.cloned().unwrap_or(existing.provider);
|
let next_provider = provider.cloned().unwrap_or(existing.provider);
|
||||||
let next_model = model.cloned().unwrap_or(existing.model);
|
let next_model = model.cloned().unwrap_or(existing.model);
|
||||||
|
|
||||||
write_expert_file(&path, name, next_description, next_body, &next_capability, &next_provider, &next_model)?;
|
write_expert_file(
|
||||||
|
&path,
|
||||||
|
name,
|
||||||
|
next_description,
|
||||||
|
next_body,
|
||||||
|
&next_capability,
|
||||||
|
&next_provider,
|
||||||
|
&next_model,
|
||||||
|
)?;
|
||||||
let expert = parse_expert_file(&path, scope.into())?;
|
let expert = parse_expert_file(&path, scope.into())?;
|
||||||
if reload {
|
if reload {
|
||||||
let _ = self.reload()?;
|
let _ = self.reload()?;
|
||||||
@ -457,7 +478,11 @@ impl ExpertRuntime {
|
|||||||
|
|
||||||
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
|
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
|
||||||
validate_expert_name(name)?;
|
validate_expert_name(name)?;
|
||||||
let config = self.config.read().expect("experts config rwlock poisoned").clone();
|
let config = self
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.expect("experts config rwlock poisoned")
|
||||||
|
.clone();
|
||||||
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
|
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
|
||||||
Ok(catalog.find_expert(name).is_some())
|
Ok(catalog.find_expert(name).is_some())
|
||||||
}
|
}
|
||||||
@ -536,10 +561,7 @@ impl ExpertRuntime {
|
|||||||
}
|
}
|
||||||
// The expert must exist (and not be disabled) for selection to be meaningful.
|
// The expert must exist (and not be disabled) for selection to be meaningful.
|
||||||
if self.get_expert(expert_name).is_none() {
|
if self.get_expert(expert_name).is_none() {
|
||||||
return Err(format!(
|
return Err(format!("expert '{}' not found or disabled", expert_name));
|
||||||
"expert '{}' not found or disabled",
|
|
||||||
expert_name
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -624,10 +646,7 @@ impl SystemPromptProvider for ExpertPromptProvider {
|
|||||||
|
|
||||||
let content = if expert.body.trim().is_empty() {
|
let content = if expert.body.trim().is_empty() {
|
||||||
// Empty body is OK; inject a header so the LLM still knows the role.
|
// Empty body is OK; inject a header so the LLM still knows the role.
|
||||||
format!(
|
format!("# 专家角色: {}\n\n{}", expert.name, expert.description)
|
||||||
"# 专家角色: {}\n\n{}",
|
|
||||||
expert.name, expert.description
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
expert.body.clone()
|
expert.body.clone()
|
||||||
};
|
};
|
||||||
@ -667,8 +686,9 @@ fn expert_state_path(scope: ExpertScope, cwd: &Path) -> PathBuf {
|
|||||||
|
|
||||||
fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result<PathBuf, String> {
|
fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result<PathBuf, String> {
|
||||||
match scope {
|
match scope {
|
||||||
ExpertScope::User => user_experts_root()
|
ExpertScope::User => {
|
||||||
.ok_or_else(|| "failed to resolve home directory".to_string()),
|
user_experts_root().ok_or_else(|| "failed to resolve home directory".to_string())
|
||||||
|
}
|
||||||
ExpertScope::Project => Ok(project_experts_root(cwd)),
|
ExpertScope::Project => Ok(project_experts_root(cwd)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1020,7 +1040,10 @@ fn load_project_session_experts(cwd: &Path) -> HashMap<String, String> {
|
|||||||
|
|
||||||
/// Persist a mutation to the project-scope state file's session_experts while
|
/// Persist a mutation to the project-scope state file's session_experts while
|
||||||
/// preserving the existing disabled_experts field.
|
/// preserving the existing disabled_experts field.
|
||||||
fn persist_session_experts<F: FnOnce(&mut ExpertStateFile)>(cwd: &Path, mutate: F) -> Result<(), String> {
|
fn persist_session_experts<F: FnOnce(&mut ExpertStateFile)>(
|
||||||
|
cwd: &Path,
|
||||||
|
mutate: F,
|
||||||
|
) -> Result<(), String> {
|
||||||
let path = project_expert_state_path(cwd);
|
let path = project_expert_state_path(cwd);
|
||||||
let mut state = load_expert_state_file(&path)?;
|
let mut state = load_expert_state_file(&path)?;
|
||||||
mutate(&mut state);
|
mutate(&mut state);
|
||||||
@ -1097,7 +1120,11 @@ mod tests {
|
|||||||
let expert_dir = dir.path().join("demo");
|
let expert_dir = dir.path().join("demo");
|
||||||
fs::create_dir_all(&expert_dir).unwrap();
|
fs::create_dir_all(&expert_dir).unwrap();
|
||||||
let expert_md = expert_dir.join("EXPERT.md");
|
let expert_md = expert_dir.join("EXPERT.md");
|
||||||
fs::write(&expert_md, "---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B").unwrap();
|
fs::write(
|
||||||
|
&expert_md,
|
||||||
|
"---\r\ndescription: demo expert\r\n---\r\nStep A\r\nStep B",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap();
|
let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap();
|
||||||
assert_eq!(expert.name, "demo");
|
assert_eq!(expert.name, "demo");
|
||||||
@ -1137,33 +1164,49 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_render_expert_file_requires_description() {
|
fn test_render_expert_file_requires_description() {
|
||||||
let err = render_expert_file("demo", " ", "body", &CapabilityPolicy::default(), &None, &None).unwrap_err();
|
let err = render_expert_file(
|
||||||
|
"demo",
|
||||||
|
" ",
|
||||||
|
"body",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert!(err.contains("description"));
|
assert!(err.contains("description"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_capability_policy_is_empty_helpers() {
|
fn test_capability_policy_is_empty_helpers() {
|
||||||
assert!(CapabilityPolicy::default().is_empty());
|
assert!(CapabilityPolicy::default().is_empty());
|
||||||
assert!(!CapabilityPolicy {
|
assert!(
|
||||||
|
!CapabilityPolicy {
|
||||||
allowed_skills: Some(vec!["a".to_string()]),
|
allowed_skills: Some(vec!["a".to_string()]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.is_empty());
|
.is_empty()
|
||||||
assert!(CapabilityPolicy {
|
);
|
||||||
|
assert!(
|
||||||
|
CapabilityPolicy {
|
||||||
denied_tools: vec![],
|
denied_tools: vec![],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.is_empty());
|
.is_empty()
|
||||||
assert!(CapabilityPolicy {
|
);
|
||||||
|
assert!(
|
||||||
|
CapabilityPolicy {
|
||||||
allowed_tools: Some(vec![]),
|
allowed_tools: Some(vec![]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.has_tool_policy());
|
.has_tool_policy()
|
||||||
assert!(CapabilityPolicy {
|
);
|
||||||
|
assert!(
|
||||||
|
CapabilityPolicy {
|
||||||
denied_skills: vec!["x".to_string()],
|
denied_skills: vec!["x".to_string()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.has_skill_policy());
|
.has_skill_policy()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1196,7 +1239,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_empty_capability_omits_keys() {
|
fn test_empty_capability_omits_keys() {
|
||||||
// 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容
|
// 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容
|
||||||
let rendered = render_expert_file("plain", "desc", "body", &CapabilityPolicy::default(), &None, &None).unwrap();
|
let rendered = render_expert_file(
|
||||||
|
"plain",
|
||||||
|
"desc",
|
||||||
|
"body",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(!rendered.contains("allowed_skills"));
|
assert!(!rendered.contains("allowed_skills"));
|
||||||
assert!(!rendered.contains("denied_skills"));
|
assert!(!rendered.contains("denied_skills"));
|
||||||
assert!(!rendered.contains("allowed_tools"));
|
assert!(!rendered.contains("allowed_tools"));
|
||||||
@ -1224,10 +1275,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// project scope (overrides user)
|
// project scope (overrides user)
|
||||||
let project_dir_expert = project_dir
|
let project_dir_expert = project_dir.join(".picobot").join("experts").join("demo");
|
||||||
.join(".picobot")
|
|
||||||
.join("experts")
|
|
||||||
.join("demo");
|
|
||||||
fs::create_dir_all(&project_dir_expert).unwrap();
|
fs::create_dir_all(&project_dir_expert).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
project_dir_expert.join("EXPERT.md"),
|
project_dir_expert.join("EXPERT.md"),
|
||||||
@ -1310,7 +1358,16 @@ mod tests {
|
|||||||
|
|
||||||
// update with None preserves fields
|
// update with None preserves fields
|
||||||
let updated_none = runtime
|
let updated_none = runtime
|
||||||
.update_expert(ExpertScope::Project, "translator", None, None, None, None, None, true)
|
.update_expert(
|
||||||
|
ExpertScope::Project,
|
||||||
|
"translator",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
true,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(updated_none.description, "更新翻译专家");
|
assert_eq!(updated_none.description, "更新翻译专家");
|
||||||
assert_eq!(updated_none.body, "你是一名中文教师。");
|
assert_eq!(updated_none.body, "你是一名中文教师。");
|
||||||
@ -1509,13 +1566,21 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let items = runtime.list_experts_with_status();
|
let items = runtime.list_experts_with_status();
|
||||||
assert_eq!(items.len(), 1, "list_experts_with_status should include disabled experts");
|
assert_eq!(
|
||||||
|
items.len(),
|
||||||
|
1,
|
||||||
|
"list_experts_with_status should include disabled experts"
|
||||||
|
);
|
||||||
assert_eq!(items[0].name, "planner");
|
assert_eq!(items[0].name, "planner");
|
||||||
assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]);
|
assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]);
|
||||||
|
|
||||||
// list_experts (filtered) should be empty
|
// list_experts (filtered) should be empty
|
||||||
let active = runtime.list_experts();
|
let active = runtime.list_experts();
|
||||||
assert_eq!(active.len(), 0, "list_experts should filter out disabled experts");
|
assert_eq!(
|
||||||
|
active.len(),
|
||||||
|
0,
|
||||||
|
"list_experts should filter out disabled experts"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1526,7 +1591,9 @@ mod tests {
|
|||||||
session_experts: HashMap::new(),
|
session_experts: HashMap::new(),
|
||||||
disabled_experts: vec!["demo".to_string()],
|
disabled_experts: vec!["demo".to_string()],
|
||||||
};
|
};
|
||||||
state.session_experts.insert("sess-1".to_string(), "demo".to_string());
|
state
|
||||||
|
.session_experts
|
||||||
|
.insert("sess-1".to_string(), "demo".to_string());
|
||||||
|
|
||||||
save_expert_state_file(&path, &state).unwrap();
|
save_expert_state_file(&path, &state).unwrap();
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use gray_matter::engine::YAML;
|
|
||||||
use gray_matter::Matter;
|
use gray_matter::Matter;
|
||||||
|
use gray_matter::engine::YAML;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
/// Parse a markdown document with YAML frontmatter into `(frontmatter, body)`.
|
/// Parse a markdown document with YAML frontmatter into `(frontmatter, body)`.
|
||||||
@ -45,7 +45,13 @@ mod tests {
|
|||||||
fn parses_lf_endings() {
|
fn parses_lf_endings() {
|
||||||
let input = "---\ndescription: demo\n---\nbody text";
|
let input = "---\ndescription: demo\n---\nbody text";
|
||||||
let (fm, body) = parse::<FrontMatter>(input).unwrap();
|
let (fm, body) = parse::<FrontMatter>(input).unwrap();
|
||||||
assert_eq!(fm, FrontMatter { description: "demo".to_string(), name: None });
|
assert_eq!(
|
||||||
|
fm,
|
||||||
|
FrontMatter {
|
||||||
|
description: "demo".to_string(),
|
||||||
|
name: None
|
||||||
|
}
|
||||||
|
);
|
||||||
assert_eq!(body, "body text");
|
assert_eq!(body, "body text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,8 @@ use crate::gateway::agent_prompt_provider::AgentPromptProvider;
|
|||||||
use crate::gateway::model_selection::ModelSelectionStore;
|
use crate::gateway::model_selection::ModelSelectionStore;
|
||||||
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
||||||
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
||||||
use crate::storage::persistent_session_id;
|
|
||||||
use crate::storage::PromptInjectionRepository;
|
use crate::storage::PromptInjectionRepository;
|
||||||
|
use crate::storage::persistent_session_id;
|
||||||
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
|
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
|
||||||
use crate::tools::{ToolContext, ToolRegistry};
|
use crate::tools::{ToolContext, ToolRegistry};
|
||||||
|
|
||||||
@ -112,7 +112,9 @@ impl AgentFactory {
|
|||||||
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
|
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
|
||||||
let expert_provider_config = match &expert {
|
let expert_provider_config = match &expert {
|
||||||
Some(e) if e.provider.is_some() || e.model.is_some() => {
|
Some(e) if e.provider.is_some() || e.model.is_some() => {
|
||||||
let resolved = self.model_resolver.resolve(
|
let resolved = self
|
||||||
|
.model_resolver
|
||||||
|
.resolve(
|
||||||
e.provider.as_deref(),
|
e.provider.as_deref(),
|
||||||
e.model.as_deref(),
|
e.model.as_deref(),
|
||||||
&request.provider_config,
|
&request.provider_config,
|
||||||
@ -133,8 +135,7 @@ impl AgentFactory {
|
|||||||
|
|
||||||
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
|
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
|
||||||
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
|
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
|
||||||
let effective_provider_config =
|
let effective_provider_config = match self.model_selections.get(&session_id) {
|
||||||
match self.model_selections.get(&session_id) {
|
|
||||||
Some((user_provider, user_model))
|
Some((user_provider, user_model))
|
||||||
if user_provider.is_some() || user_model.is_some() =>
|
if user_provider.is_some() || user_model.is_some() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@ -40,7 +40,13 @@ impl AgentTaskExecutor {
|
|||||||
options: ScheduledAgentTaskOptions,
|
options: ScheduledAgentTaskOptions,
|
||||||
) -> Result<Vec<OutboundMessage>, AgentError> {
|
) -> Result<Vec<OutboundMessage>, AgentError> {
|
||||||
self.session_manager
|
self.session_manager
|
||||||
.run_silent_agent_task(channel_name, session_chat_id, notification_chat_id, prompt, options)
|
.run_silent_agent_task(
|
||||||
|
channel_name,
|
||||||
|
session_chat_id,
|
||||||
|
notification_chat_id,
|
||||||
|
prompt,
|
||||||
|
options,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,8 +99,12 @@ impl SchedulerMaintenanceService {
|
|||||||
self.session_manager.cleanup_expired_sessions().await
|
self.session_manager.cleanup_expired_sessions().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_memory_maintenance(&self) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
|
async fn run_memory_maintenance(
|
||||||
self.session_manager.run_memory_maintenance_for_all_scopes().await
|
&self,
|
||||||
|
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
|
||||||
|
self.session_manager
|
||||||
|
.run_memory_maintenance_for_all_scopes()
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,7 +114,9 @@ impl MaintenanceExecutor for SchedulerMaintenanceService {
|
|||||||
self.cleanup_sessions().await
|
self.cleanup_sessions().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
|
async fn run_memory_maintenance_for_all_scopes(
|
||||||
|
&self,
|
||||||
|
) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
|
||||||
self.run_memory_maintenance()
|
self.run_memory_maintenance()
|
||||||
.await
|
.await
|
||||||
.map(|results| {
|
.map(|results| {
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use crate::agent::{
|
||||||
use crate::agent::{AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPromptContext};
|
AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler,
|
||||||
|
SystemPromptContext,
|
||||||
|
};
|
||||||
use crate::bus::message::ToolMessageState;
|
use crate::bus::message::ToolMessageState;
|
||||||
use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
|
use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
|
||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
use crate::storage::{persistent_session_id, ConversationRepository};
|
use crate::storage::{ConversationRepository, persistent_session_id};
|
||||||
|
use async_trait::async_trait;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use super::compaction::schedule_background_history_compaction;
|
use super::compaction::schedule_background_history_compaction;
|
||||||
@ -100,9 +103,12 @@ impl AgentExecutionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !is_current_turn {
|
if !is_current_turn {
|
||||||
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) =
|
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = session
|
||||||
session.stale_result_diagnostics(
|
.stale_result_diagnostics(
|
||||||
request.original_topic_id.as_deref().unwrap_or(request.chat_id),
|
request
|
||||||
|
.original_topic_id
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(request.chat_id),
|
||||||
);
|
);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %request.channel_name,
|
channel = %request.channel_name,
|
||||||
@ -126,10 +132,9 @@ impl AgentExecutionService {
|
|||||||
if let Some(topic_id) = target_topic_id {
|
if let Some(topic_id) = target_topic_id {
|
||||||
if is_current_turn {
|
if is_current_turn {
|
||||||
// 话题未切换(current_topic == original_topic_id),安全更新内存历史
|
// 话题未切换(current_topic == original_topic_id),安全更新内存历史
|
||||||
if let Err(err) = session.append_persisted_messages(
|
if let Err(err) = session
|
||||||
topic_id,
|
.append_persisted_messages(topic_id, request.result.emitted_messages.clone())
|
||||||
request.result.emitted_messages.clone(),
|
{
|
||||||
) {
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %err,
|
error = %err,
|
||||||
topic_id = %topic_id,
|
topic_id = %topic_id,
|
||||||
@ -153,10 +158,9 @@ impl AgentExecutionService {
|
|||||||
} else if is_current_turn {
|
} else if is_current_turn {
|
||||||
// 没有话题:直接更新内存历史(append_persisted_messages 会处理持久化)
|
// 没有话题:直接更新内存历史(append_persisted_messages 会处理持久化)
|
||||||
// 无 topic 场景用 chat_id 作为 topic_histories 的回退 key
|
// 无 topic 场景用 chat_id 作为 topic_histories 的回退 key
|
||||||
if let Err(err) = session.append_persisted_messages(
|
if let Err(err) = session
|
||||||
request.chat_id,
|
.append_persisted_messages(request.chat_id, request.result.emitted_messages.clone())
|
||||||
request.result.emitted_messages.clone(),
|
{
|
||||||
) {
|
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %err,
|
error = %err,
|
||||||
chat_id = %request.chat_id,
|
chat_id = %request.chat_id,
|
||||||
@ -274,7 +278,13 @@ impl AgentExecutionService {
|
|||||||
agent = agent.with_emitted_message_handler(handler);
|
agent = agent.with_emitted_message_handler(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
(history, agent, user_message, user_message_count, original_topic_id)
|
(
|
||||||
|
history,
|
||||||
|
agent,
|
||||||
|
user_message,
|
||||||
|
user_message_count,
|
||||||
|
original_topic_id,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 构建系统提示词上下文
|
// 构建系统提示词上下文
|
||||||
@ -324,7 +334,15 @@ impl AgentExecutionService {
|
|||||||
// 等待该 topic 的前一条消息处理完成(含压缩)
|
// 等待该 topic 的前一条消息处理完成(含压缩)
|
||||||
let _serial_guard = serial_lock.lock().await;
|
let _serial_guard = serial_lock.lock().await;
|
||||||
|
|
||||||
let (history, mut agent, user_message, user_message_count, original_topic_id, store, session_id) = {
|
let (
|
||||||
|
history,
|
||||||
|
mut agent,
|
||||||
|
user_message,
|
||||||
|
user_message_count,
|
||||||
|
original_topic_id,
|
||||||
|
store,
|
||||||
|
session_id,
|
||||||
|
) = {
|
||||||
let mut session_guard = request.session.lock().await;
|
let mut session_guard = request.session.lock().await;
|
||||||
|
|
||||||
session_guard.ensure_persistent_session(request.chat_id)?;
|
session_guard.ensure_persistent_session(request.chat_id)?;
|
||||||
@ -382,12 +400,18 @@ impl AgentExecutionService {
|
|||||||
|
|
||||||
// 获取 store 和 session_id,用于构造消息持久化 handler
|
// 获取 store 和 session_id,用于构造消息持久化 handler
|
||||||
let store = session_guard.store();
|
let store = session_guard.store();
|
||||||
let session_id = crate::storage::persistent_session_id(
|
let session_id =
|
||||||
request.channel_name,
|
crate::storage::persistent_session_id(request.channel_name, request.chat_id);
|
||||||
request.chat_id,
|
|
||||||
);
|
|
||||||
|
|
||||||
(history, agent, user_message, user_message_count, original_topic_id, store, session_id)
|
(
|
||||||
|
history,
|
||||||
|
agent,
|
||||||
|
user_message,
|
||||||
|
user_message_count,
|
||||||
|
original_topic_id,
|
||||||
|
store,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 定时任务没有 live_emitter,需要 PersistingEmittedMessageHandler 来持久化消息
|
// 定时任务没有 live_emitter,需要 PersistingEmittedMessageHandler 来持久化消息
|
||||||
@ -410,7 +434,8 @@ impl AgentExecutionService {
|
|||||||
|
|
||||||
let result = agent.process(history, Some(&system_prompt_context)).await?;
|
let result = agent.process(history, Some(&system_prompt_context)).await?;
|
||||||
|
|
||||||
let outbound_messages = self.finalize_result_and_schedule_compaction(
|
let outbound_messages = self
|
||||||
|
.finalize_result_and_schedule_compaction(
|
||||||
request.session.clone(),
|
request.session.clone(),
|
||||||
FinalizeAgentResultRequest {
|
FinalizeAgentResultRequest {
|
||||||
channel_name: request.channel_name,
|
channel_name: request.channel_name,
|
||||||
@ -543,11 +568,7 @@ mod tests {
|
|||||||
let _guard1 = lock.lock().await;
|
let _guard1 = lock.lock().await;
|
||||||
|
|
||||||
// 第二次获取应阻塞,1ms 超时验证
|
// 第二次获取应阻塞,1ms 超时验证
|
||||||
let result = tokio::time::timeout(
|
let result = tokio::time::timeout(std::time::Duration::from_millis(1), lock.lock()).await;
|
||||||
std::time::Duration::from_millis(1),
|
|
||||||
lock.lock(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_err(), "第二次获取同一锁应阻塞");
|
assert!(result.is_err(), "第二次获取同一锁应阻塞");
|
||||||
}
|
}
|
||||||
@ -561,11 +582,8 @@ mod tests {
|
|||||||
let _guard_a = lock_a.lock().await;
|
let _guard_a = lock_a.lock().await;
|
||||||
|
|
||||||
// 不同锁应立即可获取
|
// 不同锁应立即可获取
|
||||||
let result = tokio::time::timeout(
|
let result =
|
||||||
std::time::Duration::from_millis(100),
|
tokio::time::timeout(std::time::Duration::from_millis(100), lock_b.lock()).await;
|
||||||
lock_b.lock(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_ok(), "不同 topic 的锁应互不影响");
|
assert!(result.is_ok(), "不同 topic 的锁应互不影响");
|
||||||
}
|
}
|
||||||
@ -585,11 +603,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 锁应已释放,可再次获取
|
// 锁应已释放,可再次获取
|
||||||
let result = tokio::time::timeout(
|
let result = tokio::time::timeout(std::time::Duration::from_millis(100), lock.lock()).await;
|
||||||
std::time::Duration::from_millis(100),
|
|
||||||
lock.lock(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_ok(), "错误返回后锁应已释放");
|
assert!(result.is_ok(), "错误返回后锁应已释放");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
use axum::{Json, extract::{Query, State}};
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Query, State},
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@ -90,9 +93,7 @@ pub struct SaveConfigResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/config — Return current config with masked sensitive fields
|
/// GET /api/config — Return current config with masked sensitive fields
|
||||||
pub async fn get_config(
|
pub async fn get_config(State(state): State<Arc<GatewayState>>) -> Json<Config> {
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> Json<Config> {
|
|
||||||
Json(mask_config(&*state.config.read().await))
|
Json(mask_config(&*state.config.read().await))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,11 +139,19 @@ pub async fn save_config(
|
|||||||
.unwrap_or_else(|_| get_default_config_path());
|
.unwrap_or_else(|_| get_default_config_path());
|
||||||
|
|
||||||
// Serialize and write to disk (no lock held)
|
// Serialize and write to disk (no lock held)
|
||||||
let json = serde_json::to_string_pretty(&new_config)
|
let json = serde_json::to_string_pretty(&new_config).map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Serialize error: {}", e)))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Serialize error: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
std::fs::write(&config_path, &json)
|
std::fs::write(&config_path, &json).map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write error: {}", e)))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Write error: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Update in-memory config (write lock, held only for assignment)
|
// Update in-memory config (write lock, held only for assignment)
|
||||||
{
|
{
|
||||||
@ -226,9 +235,7 @@ pub async fn mcp_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/skills — Return all discovered skills with their disabled status
|
/// GET /api/skills — Return all discovered skills with their disabled status
|
||||||
pub async fn skills_list(
|
pub async fn skills_list(State(state): State<Arc<GatewayState>>) -> Json<SkillListResponse> {
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> Json<SkillListResponse> {
|
|
||||||
let skills_enabled = state.config.read().await.skills.enabled;
|
let skills_enabled = state.config.read().await.skills.enabled;
|
||||||
|
|
||||||
if !skills_enabled {
|
if !skills_enabled {
|
||||||
@ -281,9 +288,7 @@ pub struct CurrentModel {
|
|||||||
|
|
||||||
/// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source.
|
/// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source.
|
||||||
/// 通过 SessionManager::tools() 只读访问 ToolRegistry,不修改状态。
|
/// 通过 SessionManager::tools() 只读访问 ToolRegistry,不修改状态。
|
||||||
pub async fn tools_list(
|
pub async fn tools_list(State(state): State<Arc<GatewayState>>) -> Json<ToolsListResponse> {
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> Json<ToolsListResponse> {
|
|
||||||
let registry = state.session_manager.tools();
|
let registry = state.session_manager.tools();
|
||||||
let tools: Vec<ToolInfo> = registry
|
let tools: Vec<ToolInfo> = registry
|
||||||
.get_definitions()
|
.get_definitions()
|
||||||
@ -312,9 +317,7 @@ pub async fn tools_list(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。
|
/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。
|
||||||
pub async fn model_options(
|
pub async fn model_options(State(state): State<Arc<GatewayState>>) -> Json<ModelOptionsResponse> {
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> Json<ModelOptionsResponse> {
|
|
||||||
let config = state.config.read().await;
|
let config = state.config.read().await;
|
||||||
let resolver = crate::config::ModelResolver::from_config(&config);
|
let resolver = crate::config::ModelResolver::from_config(&config);
|
||||||
// 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key)
|
// 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key)
|
||||||
@ -371,7 +374,11 @@ pub async fn skills_toggle(
|
|||||||
changed: Some(change.changed),
|
changed: Some(change.changed),
|
||||||
available: Some(change.available),
|
available: Some(change.available),
|
||||||
disabled_in_scopes: Some(
|
disabled_in_scopes: Some(
|
||||||
change.disabled_in_scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
change
|
||||||
|
.disabled_in_scopes
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str().to_string())
|
||||||
|
.collect(),
|
||||||
),
|
),
|
||||||
error: None,
|
error: None,
|
||||||
}),
|
}),
|
||||||
@ -424,9 +431,7 @@ pub struct SubagentListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/subagents — Return all discovered subagents with their disabled status
|
/// GET /api/subagents — Return all discovered subagents with their disabled status
|
||||||
pub async fn subagents_list(
|
pub async fn subagents_list(State(state): State<Arc<GatewayState>>) -> Json<SubagentListResponse> {
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> Json<SubagentListResponse> {
|
|
||||||
let subagents_enabled = state.config.read().await.subagents.enabled;
|
let subagents_enabled = state.config.read().await.subagents.enabled;
|
||||||
|
|
||||||
if !subagents_enabled {
|
if !subagents_enabled {
|
||||||
@ -725,9 +730,7 @@ pub struct ExpertDeleteResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/experts — Return all discovered experts with their disabled status
|
/// GET /api/experts — Return all discovered experts with their disabled status
|
||||||
pub async fn experts_list(
|
pub async fn experts_list(State(state): State<Arc<GatewayState>>) -> Json<ExpertListResponse> {
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> Json<ExpertListResponse> {
|
|
||||||
let experts_enabled = state.config.read().await.experts.enabled;
|
let experts_enabled = state.config.read().await.experts.enabled;
|
||||||
|
|
||||||
if !experts_enabled {
|
if !experts_enabled {
|
||||||
@ -817,8 +820,12 @@ pub async fn experts_create(
|
|||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Json(req): Json<ExpertCreateRequest>,
|
Json(req): Json<ExpertCreateRequest>,
|
||||||
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
|
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
|
||||||
let scope = ExpertScope::parse(&req.scope)
|
let scope = ExpertScope::parse(&req.scope).ok_or_else(|| {
|
||||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("invalid scope: {}", req.scope),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let expert = state
|
let expert = state
|
||||||
.experts
|
.experts
|
||||||
@ -849,8 +856,12 @@ pub async fn experts_update(
|
|||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Json(req): Json<ExpertUpdateRequest>,
|
Json(req): Json<ExpertUpdateRequest>,
|
||||||
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
|
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
|
||||||
let scope = ExpertScope::parse(&req.scope)
|
let scope = ExpertScope::parse(&req.scope).ok_or_else(|| {
|
||||||
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("invalid scope: {}", req.scope),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let expert = state
|
let expert = state
|
||||||
.experts
|
.experts
|
||||||
@ -1011,9 +1022,7 @@ pub async fn session_select_model(
|
|||||||
}
|
}
|
||||||
drop(config);
|
drop(config);
|
||||||
|
|
||||||
state
|
state.model_selections.set(&req.session_id, provider, model);
|
||||||
.model_selections
|
|
||||||
.set(&req.session_id, provider, model);
|
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(SelectModelResponse {
|
Json(SelectModelResponse {
|
||||||
|
|||||||
@ -227,8 +227,8 @@ impl MemoryMaintenanceService {
|
|||||||
Ok(parsed) => return Ok(parsed),
|
Ok(parsed) => return Ok(parsed),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let error_msg = err.to_string();
|
let error_msg = err.to_string();
|
||||||
let is_truncated = error_msg.contains("EOF while parsing")
|
let is_truncated =
|
||||||
|| error_msg.contains("expected");
|
error_msg.contains("EOF while parsing") || error_msg.contains("expected");
|
||||||
|
|
||||||
let should_retry = delay_ms.is_some() && is_truncated;
|
let should_retry = delay_ms.is_some() && is_truncated;
|
||||||
last_error = Some(error_msg.clone());
|
last_error = Some(error_msg.clone());
|
||||||
@ -369,9 +369,10 @@ impl MemoryMaintenanceService {
|
|||||||
pub(crate) async fn run_for_all_scopes(
|
pub(crate) async fn run_for_all_scopes(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
|
) -> Result<Option<MemoryMaintenanceScopeResult>, AgentError> {
|
||||||
let scope_keys = self.store.list_memory_scope_keys("user").map_err(|err| {
|
let scope_keys = self
|
||||||
AgentError::Other(format!("list memory scope keys error: {}", err))
|
.store
|
||||||
})?;
|
.list_memory_scope_keys("user")
|
||||||
|
.map_err(|err| AgentError::Other(format!("list memory scope keys error: {}", err)))?;
|
||||||
|
|
||||||
if scope_keys.is_empty() {
|
if scope_keys.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@ -418,7 +419,8 @@ impl MemoryMaintenanceService {
|
|||||||
let managed_markdown = if all_remaining_memories.is_empty() {
|
let managed_markdown = if all_remaining_memories.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
self.generate_summary("all", &all_remaining_memories).await?
|
self.generate_summary("all", &all_remaining_memories)
|
||||||
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
if !managed_markdown.is_empty() {
|
if !managed_markdown.is_empty() {
|
||||||
@ -678,24 +680,29 @@ pub(crate) fn validate_memory_maintenance_output(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证 2: 跨 namespace 合并检测(完全禁止)
|
// 验证 2: 跨 namespace 合并检测(完全禁止)
|
||||||
let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> = plan
|
let candidates_by_id: HashMap<&str, &MemoryMaintenanceCandidate> =
|
||||||
.candidates
|
plan.candidates.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||||
.iter()
|
|
||||||
.map(|c| (c.id.as_str(), c))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for merge in &output.merges {
|
for merge in &output.merges {
|
||||||
let source_namespaces: HashSet<&str> = merge
|
let source_namespaces: HashSet<&str> = merge
|
||||||
.source_ids
|
.source_ids
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|id| candidates_by_id.get(id.as_str()).map(|c| c.namespace.as_str()))
|
.filter_map(|id| {
|
||||||
|
candidates_by_id
|
||||||
|
.get(id.as_str())
|
||||||
|
.map(|c| c.namespace.as_str())
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 检查是否跨越多个 namespace
|
// 检查是否跨越多个 namespace
|
||||||
if source_namespaces.len() > 1 {
|
if source_namespaces.len() > 1 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"跨 namespace 合并被禁止: 源来自 {}",
|
"跨 namespace 合并被禁止: 源来自 {}",
|
||||||
source_namespaces.iter().cloned().collect::<Vec<_>>().join(", ")
|
source_namespaces
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -718,11 +725,7 @@ pub(crate) fn validate_memory_maintenance_output(
|
|||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let deleted_ids: HashSet<&str> = output
|
let deleted_ids: HashSet<&str> = output.low_value_ids.iter().map(|s| s.as_str()).collect();
|
||||||
.low_value_ids
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let affected = merged_ids.len() + deleted_ids.len();
|
let affected = merged_ids.len() + deleted_ids.len();
|
||||||
let max_allowed = (total as f32 * max_merge_ratio).ceil() as usize;
|
let max_allowed = (total as f32 * max_merge_ratio).ceil() as usize;
|
||||||
@ -758,7 +761,13 @@ pub(crate) fn apply_memory_maintenance_output(
|
|||||||
max_merge_per_group: usize,
|
max_merge_per_group: usize,
|
||||||
) -> Result<(), AgentError> {
|
) -> Result<(), AgentError> {
|
||||||
// 新增: 验证合并输出
|
// 新增: 验证合并输出
|
||||||
validate_memory_maintenance_output(plan, output, max_merge_ratio, min_memories_to_keep, max_merge_per_group)
|
validate_memory_maintenance_output(
|
||||||
|
plan,
|
||||||
|
output,
|
||||||
|
max_merge_ratio,
|
||||||
|
min_memories_to_keep,
|
||||||
|
max_merge_per_group,
|
||||||
|
)
|
||||||
.map_err(|e| AgentError::Other(e))?;
|
.map_err(|e| AgentError::Other(e))?;
|
||||||
|
|
||||||
let all_candidates = plan.candidates.clone();
|
let all_candidates = plan.candidates.clone();
|
||||||
|
|||||||
@ -25,8 +25,8 @@ pub mod session_message_sender;
|
|||||||
pub mod session_message_service;
|
pub mod session_message_service;
|
||||||
pub mod session_pool;
|
pub mod session_pool;
|
||||||
pub mod static_files;
|
pub mod static_files;
|
||||||
pub mod tool_registry_factory;
|
|
||||||
pub mod tool_prompt_provider;
|
pub mod tool_prompt_provider;
|
||||||
|
pub mod tool_registry_factory;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
use axum::{Router, routing};
|
use axum::{Router, routing};
|
||||||
@ -50,11 +50,11 @@ use cancel_manager::CancelManager;
|
|||||||
use outbound_dispatcher::OutboundDispatcher;
|
use outbound_dispatcher::OutboundDispatcher;
|
||||||
use processor::InboundProcessor;
|
use processor::InboundProcessor;
|
||||||
use runtime::build_session_manager_with_sender;
|
use runtime::build_session_manager_with_sender;
|
||||||
use session_message_sender::BusSessionMessageSender;
|
|
||||||
use session::SessionManager;
|
use session::SessionManager;
|
||||||
|
use session_message_sender::BusSessionMessageSender;
|
||||||
use static_files::static_handler;
|
use static_files::static_handler;
|
||||||
|
|
||||||
use tokio::sync::{watch, RwLock};
|
use tokio::sync::{RwLock, watch};
|
||||||
|
|
||||||
pub struct GatewayState {
|
pub struct GatewayState {
|
||||||
pub config: Arc<RwLock<Config>>,
|
pub config: Arc<RwLock<Config>>,
|
||||||
@ -73,7 +73,10 @@ pub struct GatewayState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayState {
|
impl GatewayState {
|
||||||
pub fn from_config(config: Config, restart_tx: watch::Sender<bool>) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn from_config(
|
||||||
|
config: Config,
|
||||||
|
restart_tx: watch::Sender<bool>,
|
||||||
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
// Get provider config for SessionManager
|
// Get provider config for SessionManager
|
||||||
let provider_config = config.get_provider_config("default")?;
|
let provider_config = config.get_provider_config("default")?;
|
||||||
let mut provider_configs = HashMap::<String, LLMProviderConfig>::new();
|
let mut provider_configs = HashMap::<String, LLMProviderConfig>::new();
|
||||||
@ -87,7 +90,9 @@ impl GatewayState {
|
|||||||
let session_ttl_hours = config.gateway.session_ttl_hours;
|
let session_ttl_hours = config.gateway.session_ttl_hours;
|
||||||
|
|
||||||
let skills = Arc::new(SkillRuntime::from_config(config.skills.clone()));
|
let skills = Arc::new(SkillRuntime::from_config(config.skills.clone()));
|
||||||
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(config.experts.clone()));
|
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
|
||||||
|
config.experts.clone(),
|
||||||
|
));
|
||||||
let channel_manager = ChannelManager::new();
|
let channel_manager = ChannelManager::new();
|
||||||
let bus = channel_manager.bus();
|
let bus = channel_manager.bus();
|
||||||
|
|
||||||
@ -95,7 +100,8 @@ impl GatewayState {
|
|||||||
mcp_servers: config.mcp_servers.clone(),
|
mcp_servers: config.mcp_servers.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) = build_session_manager_with_sender(
|
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) =
|
||||||
|
build_session_manager_with_sender(
|
||||||
agent_prompt_reinject_every,
|
agent_prompt_reinject_every,
|
||||||
show_tool_results,
|
show_tool_results,
|
||||||
config.time.timezone.clone(),
|
config.time.timezone.clone(),
|
||||||
@ -155,8 +161,13 @@ impl GatewayState {
|
|||||||
drop(cfg); // release read lock before spawning long-running tasks
|
drop(cfg); // release read lock before spawning long-running tasks
|
||||||
|
|
||||||
let semaphore = Arc::new(Semaphore::new(max_concurrent));
|
let semaphore = Arc::new(Semaphore::new(max_concurrent));
|
||||||
let inbound_processor =
|
let inbound_processor = InboundProcessor::new(
|
||||||
InboundProcessor::new(self.bus.clone(), self.session_manager.clone(), semaphore, provider_config, self.cancel_manager.clone());
|
self.bus.clone(),
|
||||||
|
self.session_manager.clone(),
|
||||||
|
semaphore,
|
||||||
|
provider_config,
|
||||||
|
self.cancel_manager.clone(),
|
||||||
|
);
|
||||||
tokio::spawn(inbound_processor.run());
|
tokio::spawn(inbound_processor.run());
|
||||||
|
|
||||||
// Spawn outbound dispatcher
|
// Spawn outbound dispatcher
|
||||||
@ -241,7 +252,10 @@ pub async fn run(
|
|||||||
let app = if use_embedded {
|
let app = if use_embedded {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", routing::get(http::health))
|
.route("/health", routing::get(http::health))
|
||||||
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
.route(
|
||||||
|
"/api/config",
|
||||||
|
routing::get(http::get_config).put(http::save_config),
|
||||||
|
)
|
||||||
.route("/api/restart", routing::post(http::restart))
|
.route("/api/restart", routing::post(http::restart))
|
||||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||||
.route("/api/skills", routing::get(http::skills_list))
|
.route("/api/skills", routing::get(http::skills_list))
|
||||||
@ -249,17 +263,32 @@ pub async fn run(
|
|||||||
.route("/api/tools", routing::get(http::tools_list))
|
.route("/api/tools", routing::get(http::tools_list))
|
||||||
.route("/api/model-options", routing::get(http::model_options))
|
.route("/api/model-options", routing::get(http::model_options))
|
||||||
.route("/api/subagents", routing::get(http::subagents_list))
|
.route("/api/subagents", routing::get(http::subagents_list))
|
||||||
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
.route(
|
||||||
.route("/api/subagents/update", routing::put(http::subagents_update))
|
"/api/subagents/toggle",
|
||||||
|
routing::post(http::subagents_toggle),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/subagents/update",
|
||||||
|
routing::put(http::subagents_update),
|
||||||
|
)
|
||||||
.route("/api/experts", routing::get(http::experts_list))
|
.route("/api/experts", routing::get(http::experts_list))
|
||||||
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
||||||
.route("/api/experts/create", routing::post(http::experts_create))
|
.route("/api/experts/create", routing::post(http::experts_create))
|
||||||
.route("/api/experts/update", routing::put(http::experts_update))
|
.route("/api/experts/update", routing::put(http::experts_update))
|
||||||
.route("/api/experts/delete", routing::delete(http::experts_delete))
|
.route("/api/experts/delete", routing::delete(http::experts_delete))
|
||||||
.route("/api/experts/selected", routing::get(http::experts_selected))
|
.route(
|
||||||
|
"/api/experts/selected",
|
||||||
|
routing::get(http::experts_selected),
|
||||||
|
)
|
||||||
.route("/api/experts/select", routing::post(http::experts_select))
|
.route("/api/experts/select", routing::post(http::experts_select))
|
||||||
.route("/api/session/select-model", routing::post(http::session_select_model))
|
.route(
|
||||||
.route("/api/session/selected-model", routing::get(http::session_selected_model))
|
"/api/session/select-model",
|
||||||
|
routing::post(http::session_select_model),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/session/selected-model",
|
||||||
|
routing::get(http::session_selected_model),
|
||||||
|
)
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.fallback(static_handler)
|
.fallback(static_handler)
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
@ -267,7 +296,10 @@ pub async fn run(
|
|||||||
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string());
|
let static_dir = std::env::var("STATIC_DIR").unwrap_or_else(|_| "static".to_string());
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", routing::get(http::health))
|
.route("/health", routing::get(http::health))
|
||||||
.route("/api/config", routing::get(http::get_config).put(http::save_config))
|
.route(
|
||||||
|
"/api/config",
|
||||||
|
routing::get(http::get_config).put(http::save_config),
|
||||||
|
)
|
||||||
.route("/api/restart", routing::post(http::restart))
|
.route("/api/restart", routing::post(http::restart))
|
||||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||||
.route("/api/skills", routing::get(http::skills_list))
|
.route("/api/skills", routing::get(http::skills_list))
|
||||||
@ -275,17 +307,32 @@ pub async fn run(
|
|||||||
.route("/api/tools", routing::get(http::tools_list))
|
.route("/api/tools", routing::get(http::tools_list))
|
||||||
.route("/api/model-options", routing::get(http::model_options))
|
.route("/api/model-options", routing::get(http::model_options))
|
||||||
.route("/api/subagents", routing::get(http::subagents_list))
|
.route("/api/subagents", routing::get(http::subagents_list))
|
||||||
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
.route(
|
||||||
.route("/api/subagents/update", routing::put(http::subagents_update))
|
"/api/subagents/toggle",
|
||||||
|
routing::post(http::subagents_toggle),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/subagents/update",
|
||||||
|
routing::put(http::subagents_update),
|
||||||
|
)
|
||||||
.route("/api/experts", routing::get(http::experts_list))
|
.route("/api/experts", routing::get(http::experts_list))
|
||||||
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
||||||
.route("/api/experts/create", routing::post(http::experts_create))
|
.route("/api/experts/create", routing::post(http::experts_create))
|
||||||
.route("/api/experts/update", routing::put(http::experts_update))
|
.route("/api/experts/update", routing::put(http::experts_update))
|
||||||
.route("/api/experts/delete", routing::delete(http::experts_delete))
|
.route("/api/experts/delete", routing::delete(http::experts_delete))
|
||||||
.route("/api/experts/selected", routing::get(http::experts_selected))
|
.route(
|
||||||
|
"/api/experts/selected",
|
||||||
|
routing::get(http::experts_selected),
|
||||||
|
)
|
||||||
.route("/api/experts/select", routing::post(http::experts_select))
|
.route("/api/experts/select", routing::post(http::experts_select))
|
||||||
.route("/api/session/select-model", routing::post(http::session_select_model))
|
.route(
|
||||||
.route("/api/session/selected-model", routing::get(http::session_selected_model))
|
"/api/session/select-model",
|
||||||
|
routing::post(http::session_select_model),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/session/selected-model",
|
||||||
|
routing::get(http::session_selected_model),
|
||||||
|
)
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.fallback_service(ServeDir::new(&static_dir))
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
|
|||||||
@ -16,12 +16,7 @@ impl ModelSelectionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
|
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
|
||||||
pub fn set(
|
pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
|
||||||
&self,
|
|
||||||
session_id: &str,
|
|
||||||
provider: Option<String>,
|
|
||||||
model: Option<String>,
|
|
||||||
) {
|
|
||||||
let mut selections = self
|
let mut selections = self
|
||||||
.selections
|
.selections
|
||||||
.write()
|
.write()
|
||||||
@ -76,9 +71,6 @@ mod tests {
|
|||||||
fn set_only_provider_keeps_entry() {
|
fn set_only_provider_keeps_entry() {
|
||||||
let store = ModelSelectionStore::new();
|
let store = ModelSelectionStore::new();
|
||||||
store.set("s1", Some("p1".to_string()), None);
|
store.set("s1", Some("p1".to_string()), None);
|
||||||
assert_eq!(
|
assert_eq!(store.get("s1"), Some((Some("p1".to_string()), None)));
|
||||||
store.get("s1"),
|
|
||||||
Some((Some("p1".to_string()), None))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
|
|||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
use crate::gateway::agent_factory::build_system_prompt_provider;
|
use crate::gateway::agent_factory::build_system_prompt_provider;
|
||||||
use crate::gateway::cancel_manager::CancelManager;
|
use crate::gateway::cancel_manager::CancelManager;
|
||||||
use crate::providers::{create_provider, ProviderRuntimeConfig};
|
use crate::providers::{ProviderRuntimeConfig, create_provider};
|
||||||
use crate::storage::persistent_session_id;
|
use crate::storage::persistent_session_id;
|
||||||
use crate::topic_description::generate_topic_description;
|
use crate::topic_description::generate_topic_description;
|
||||||
|
|
||||||
@ -52,8 +52,8 @@ impl InboundProcessor {
|
|||||||
let store = session_manager.store();
|
let store = session_manager.store();
|
||||||
|
|
||||||
// 注册 Session 处理器
|
// 注册 Session 处理器
|
||||||
let session_handler = SessionCommandHandler::new(store.clone())
|
let session_handler =
|
||||||
.with_session_manager(session_manager.clone());
|
SessionCommandHandler::new(store.clone()).with_session_manager(session_manager.clone());
|
||||||
command_router.register(Box::new(session_handler));
|
command_router.register(Box::new(session_handler));
|
||||||
|
|
||||||
// 注册 list_sessions 处理器
|
// 注册 list_sessions 处理器
|
||||||
@ -79,7 +79,7 @@ impl InboundProcessor {
|
|||||||
// 注册 get_current 处理器
|
// 注册 get_current 处理器
|
||||||
command_router.register(Box::new(
|
command_router.register(Box::new(
|
||||||
GetCurrentSessionCommandHandler::new(store.clone())
|
GetCurrentSessionCommandHandler::new(store.clone())
|
||||||
.with_system_prompt_provider(system_prompt_provider.clone())
|
.with_system_prompt_provider(system_prompt_provider.clone()),
|
||||||
));
|
));
|
||||||
|
|
||||||
// 注册 load_topic 处理器
|
// 注册 load_topic 处理器
|
||||||
@ -185,7 +185,8 @@ impl InboundProcessor {
|
|||||||
let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id);
|
let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id);
|
||||||
|
|
||||||
// 获取当前话题(封装了 session 创建逻辑)
|
// 获取当前话题(封装了 session 创建逻辑)
|
||||||
let current_topic = self.session_manager
|
let current_topic = self
|
||||||
|
.session_manager
|
||||||
.get_current_topic(&inbound.channel, &inbound.chat_id)
|
.get_current_topic(&inbound.channel, &inbound.chat_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -196,7 +197,8 @@ impl InboundProcessor {
|
|||||||
|
|
||||||
if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) {
|
if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) {
|
||||||
// 使用命令路由器处理
|
// 使用命令路由器处理
|
||||||
let mut cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
|
let mut cmd_ctx =
|
||||||
|
crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
|
||||||
.with_session_id(&session_id)
|
.with_session_id(&session_id)
|
||||||
.with_chat_id(&inbound.chat_id);
|
.with_chat_id(&inbound.chat_id);
|
||||||
// 只在有话题时才设置 topic_id
|
// 只在有话题时才设置 topic_id
|
||||||
@ -204,7 +206,10 @@ impl InboundProcessor {
|
|||||||
cmd_ctx = cmd_ctx.with_topic_id(topic_id.as_str());
|
cmd_ctx = cmd_ctx.with_topic_id(topic_id.as_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = self.command_router.dispatch_with_response(cmd, cmd_ctx).await;
|
let response = self
|
||||||
|
.command_router
|
||||||
|
.dispatch_with_response(cmd, cmd_ctx)
|
||||||
|
.await;
|
||||||
|
|
||||||
// 发送响应给用户
|
// 发送响应给用户
|
||||||
if response.success {
|
if response.success {
|
||||||
@ -295,7 +300,9 @@ impl InboundProcessor {
|
|||||||
outbound.metadata.extend(inbound.forwarded_metadata.clone());
|
outbound.metadata.extend(inbound.forwarded_metadata.clone());
|
||||||
// 注入 topic_id 到 outbound metadata,用于前端按话题隔离消息
|
// 注入 topic_id 到 outbound metadata,用于前端按话题隔离消息
|
||||||
if let Some(ref topic_id) = current_topic {
|
if let Some(ref topic_id) = current_topic {
|
||||||
outbound.metadata.insert("topic_id".to_string(), topic_id.clone());
|
outbound
|
||||||
|
.metadata
|
||||||
|
.insert("topic_id".to_string(), topic_id.clone());
|
||||||
}
|
}
|
||||||
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
if let Err(error) = self.bus.publish_outbound(outbound).await {
|
||||||
tracing::error!(error = %error, "Failed to publish outbound");
|
tracing::error!(error = %error, "Failed to publish outbound");
|
||||||
@ -306,10 +313,17 @@ impl InboundProcessor {
|
|||||||
if let Some(ref topic_id) = current_topic {
|
if let Some(ref topic_id) = current_topic {
|
||||||
let store = self.session_manager.store();
|
let store = self.session_manager.store();
|
||||||
if let Ok(Some(topic)) = store.get_topic(topic_id) {
|
if let Ok(Some(topic)) = store.get_topic(topic_id) {
|
||||||
if topic.description.is_none() || topic.description.as_ref().map(|d| d.is_empty()).unwrap_or(true) {
|
if topic.description.is_none()
|
||||||
|
|| topic
|
||||||
|
.description
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.is_empty())
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
|
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
|
||||||
let should_generate = {
|
let should_generate = {
|
||||||
let mut in_flight = self.description_generation_in_flight.lock().unwrap();
|
let mut in_flight =
|
||||||
|
self.description_generation_in_flight.lock().unwrap();
|
||||||
if in_flight.contains(topic_id) {
|
if in_flight.contains(topic_id) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@ -329,7 +343,9 @@ impl InboundProcessor {
|
|||||||
let first_user_message = store_clone
|
let first_user_message = store_clone
|
||||||
.load_messages_for_topic(&topic_id_clone, None)
|
.load_messages_for_topic(&topic_id_clone, None)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|msgs| msgs.into_iter().find(|m| m.role == "user"))
|
.and_then(|msgs| {
|
||||||
|
msgs.into_iter().find(|m| m.role == "user")
|
||||||
|
})
|
||||||
.map(|m| m.content);
|
.map(|m| m.content);
|
||||||
|
|
||||||
let message_content = match first_user_message {
|
let message_content = match first_user_message {
|
||||||
@ -341,11 +357,22 @@ impl InboundProcessor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let runtime_config: ProviderRuntimeConfig = provider_config.into();
|
let runtime_config: ProviderRuntimeConfig =
|
||||||
|
provider_config.into();
|
||||||
if let Ok(provider) = create_provider(runtime_config) {
|
if let Ok(provider) = create_provider(runtime_config) {
|
||||||
match generate_topic_description(provider.as_ref(), &message_content).await {
|
match generate_topic_description(
|
||||||
|
provider.as_ref(),
|
||||||
|
&message_content,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(description) => {
|
Ok(description) => {
|
||||||
if let Err(e) = store_clone.update_topic_description(&topic_id_clone, &description) {
|
if let Err(e) = store_clone
|
||||||
|
.update_topic_description(
|
||||||
|
&topic_id_clone,
|
||||||
|
&description,
|
||||||
|
)
|
||||||
|
{
|
||||||
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description");
|
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description");
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
|
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
|
||||||
|
|||||||
@ -57,8 +57,9 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result<Option<String>,
|
|||||||
ensure_parent_dir(path)?;
|
ensure_parent_dir(path)?;
|
||||||
// 文件不存在时创建空白模板
|
// 文件不存在时创建空白模板
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
fs::write(path, template)
|
fs::write(path, template).map_err(|err| {
|
||||||
.map_err(|err| AgentError::Other(format!("create AGENT.md template error: {}", err)))?;
|
AgentError::Other(format!("create AGENT.md template error: {}", err))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
// 读取内容,仅当非空(去除注释后)时注入
|
// 读取内容,仅当非空(去除注释后)时注入
|
||||||
let content = fs::read_to_string(path)
|
let content = fs::read_to_string(path)
|
||||||
@ -70,8 +71,9 @@ fn load_prompt_from_sources(sources: &[PromptSource]) -> Result<Option<String>,
|
|||||||
}
|
}
|
||||||
PromptSource::AutoGenerated(path) => {
|
PromptSource::AutoGenerated(path) => {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let content = fs::read_to_string(path)
|
let content = fs::read_to_string(path).map_err(|err| {
|
||||||
.map_err(|err| AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err)))?;
|
AgentError::Other(format!("read MEMORY_SUMMARY.md error: {}", err))
|
||||||
|
})?;
|
||||||
let without_comments = strip_comments_and_whitespace(&content);
|
let without_comments = strip_comments_and_whitespace(&content);
|
||||||
if !without_comments.is_empty() {
|
if !without_comments.is_empty() {
|
||||||
fragments.push(without_comments);
|
fragments.push(without_comments);
|
||||||
@ -337,6 +339,9 @@ mod tests {
|
|||||||
|
|
||||||
persist_memory_summary(&memory_path, "\n## 用户记忆摘要\n- 偏好简洁\n\n").unwrap();
|
persist_memory_summary(&memory_path, "\n## 用户记忆摘要\n- 偏好简洁\n\n").unwrap();
|
||||||
|
|
||||||
assert_eq!(fs::read_to_string(&memory_path).unwrap(), "## 用户记忆摘要\n- 偏好简洁\n");
|
assert_eq!(
|
||||||
|
fs::read_to_string(&memory_path).unwrap(),
|
||||||
|
"## 用户记忆摘要\n- 偏好简洁\n"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,9 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use crate::agent::AgentError;
|
use crate::agent::AgentError;
|
||||||
use crate::bus::MessageBus;
|
use crate::bus::MessageBus;
|
||||||
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig};
|
use crate::config::{
|
||||||
|
LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig,
|
||||||
|
};
|
||||||
use crate::gateway::model_selection::ModelSelectionStore;
|
use crate::gateway::model_selection::ModelSelectionStore;
|
||||||
use crate::gateway::tool_registry_factory::ToolRegistryFactory;
|
use crate::gateway::tool_registry_factory::ToolRegistryFactory;
|
||||||
use crate::mcp::McpInitializer;
|
use crate::mcp::McpInitializer;
|
||||||
@ -18,13 +20,13 @@ use crate::storage::{
|
|||||||
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
|
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
|
||||||
SessionStore, SkillEventRepository, TodoRepository,
|
SessionStore, SkillEventRepository, TodoRepository,
|
||||||
};
|
};
|
||||||
use crate::tools::task::runtime::SubagentRuntime;
|
|
||||||
use crate::tools::{
|
|
||||||
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender,
|
|
||||||
SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
|
|
||||||
};
|
|
||||||
use crate::tools::task::repository::TaskRepository;
|
use crate::tools::task::repository::TaskRepository;
|
||||||
|
use crate::tools::task::runtime::SubagentRuntime;
|
||||||
use crate::tools::todo_write::TodoItem;
|
use crate::tools::todo_write::TodoItem;
|
||||||
|
use crate::tools::{
|
||||||
|
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, SessionMessageSender,
|
||||||
|
SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
|
||||||
|
};
|
||||||
|
|
||||||
use super::agent_factory::AgentFactory;
|
use super::agent_factory::AgentFactory;
|
||||||
use super::cli_session::CliSessionService;
|
use super::cli_session::CliSessionService;
|
||||||
@ -55,7 +57,16 @@ pub(crate) fn build_session_manager(
|
|||||||
mcp_config: crate::mcp::McpConfig,
|
mcp_config: crate::mcp::McpConfig,
|
||||||
bus: Option<Arc<MessageBus>>,
|
bus: Option<Arc<MessageBus>>,
|
||||||
model_resolver: Arc<ModelResolver>,
|
model_resolver: Arc<ModelResolver>,
|
||||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> {
|
) -> Result<
|
||||||
|
(
|
||||||
|
SessionManager,
|
||||||
|
Arc<dyn TaskRepository>,
|
||||||
|
Option<Arc<McpClientManager>>,
|
||||||
|
Arc<SubagentRuntime>,
|
||||||
|
Arc<ModelSelectionStore>,
|
||||||
|
),
|
||||||
|
AgentError,
|
||||||
|
> {
|
||||||
build_session_manager_with_sender(
|
build_session_manager_with_sender(
|
||||||
agent_prompt_reinject_every,
|
agent_prompt_reinject_every,
|
||||||
show_tool_results,
|
show_tool_results,
|
||||||
@ -94,7 +105,16 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
mcp_config: crate::mcp::McpConfig,
|
mcp_config: crate::mcp::McpConfig,
|
||||||
bus: Option<Arc<MessageBus>>,
|
bus: Option<Arc<MessageBus>>,
|
||||||
model_resolver: Arc<ModelResolver>,
|
model_resolver: Arc<ModelResolver>,
|
||||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> {
|
) -> Result<
|
||||||
|
(
|
||||||
|
SessionManager,
|
||||||
|
Arc<dyn TaskRepository>,
|
||||||
|
Option<Arc<McpClientManager>>,
|
||||||
|
Arc<SubagentRuntime>,
|
||||||
|
Arc<ModelSelectionStore>,
|
||||||
|
),
|
||||||
|
AgentError,
|
||||||
|
> {
|
||||||
let store = Arc::new(
|
let store = Arc::new(
|
||||||
SessionStore::new()
|
SessionStore::new()
|
||||||
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
||||||
@ -181,18 +201,20 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create SubAgentRuntime (if task tool is enabled)
|
// Create SubAgentRuntime (if task tool is enabled)
|
||||||
let (factory, task_repository, subagent_runtime): (_, Arc<dyn TaskRepository>, Arc<SubagentRuntime>) = if task_config.enabled {
|
let (factory, task_repository, subagent_runtime): (
|
||||||
|
_,
|
||||||
|
Arc<dyn TaskRepository>,
|
||||||
|
Arc<SubagentRuntime>,
|
||||||
|
) = if task_config.enabled {
|
||||||
let task_repository = Arc::new(InMemoryTaskRepository::new());
|
let task_repository = Arc::new(InMemoryTaskRepository::new());
|
||||||
// Build subagent tools with MCP tools (task tool registered separately below)
|
// Build subagent tools with MCP tools (task tool registered separately below)
|
||||||
let subagent_tools = Arc::new(
|
let subagent_tools = Arc::new(factory.build_subagent_tools(
|
||||||
factory.build_subagent_tools(
|
|
||||||
if mcp_tools_for_subagents.is_empty() {
|
if mcp_tools_for_subagents.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(mcp_tools_for_subagents.clone())
|
Some(mcp_tools_for_subagents.clone())
|
||||||
}
|
},
|
||||||
)
|
));
|
||||||
);
|
|
||||||
|
|
||||||
// Create subagent catalog with discovery, wrap in SubagentRuntime
|
// Create subagent catalog with discovery, wrap in SubagentRuntime
|
||||||
let catalog = SubagentCatalog::discover(&subagents_config);
|
let catalog = SubagentCatalog::discover(&subagents_config);
|
||||||
@ -230,11 +252,19 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
(factory.with_subagent_runtime(default_subagent_runtime), task_repository, subagent_runtime)
|
(
|
||||||
|
factory.with_subagent_runtime(default_subagent_runtime),
|
||||||
|
task_repository,
|
||||||
|
subagent_runtime,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// task_config 未启用时仍创建 subagent_runtime(供 API 使用)
|
// task_config 未启用时仍创建 subagent_runtime(供 API 使用)
|
||||||
let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone()));
|
let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone()));
|
||||||
(factory, Arc::new(InMemoryTaskRepository::new()), subagent_runtime)
|
(
|
||||||
|
factory,
|
||||||
|
Arc::new(InMemoryTaskRepository::new()),
|
||||||
|
subagent_runtime,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build base tools
|
// Build base tools
|
||||||
@ -306,7 +336,8 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
// Extract MCP manager for lifecycle management (e.g., disconnect on restart)
|
// Extract MCP manager for lifecycle management (e.g., disconnect on restart)
|
||||||
let mcp_manager = mcp_initializer.manager();
|
let mcp_manager = mcp_initializer.manager();
|
||||||
|
|
||||||
Ok((SessionManager::from_services(SessionManagerServices {
|
Ok((
|
||||||
|
SessionManager::from_services(SessionManagerServices {
|
||||||
tools: tools as Arc<ToolRegistry>,
|
tools: tools as Arc<ToolRegistry>,
|
||||||
skills,
|
skills,
|
||||||
experts,
|
experts,
|
||||||
@ -319,5 +350,10 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
scheduled_tasks,
|
scheduled_tasks,
|
||||||
memory_maintenance,
|
memory_maintenance,
|
||||||
task_repository: task_repository.clone(),
|
task_repository: task_repository.clone(),
|
||||||
}), task_repository, mcp_manager, subagent_runtime, model_selections))
|
}),
|
||||||
|
task_repository,
|
||||||
|
mcp_manager,
|
||||||
|
subagent_runtime,
|
||||||
|
model_selections,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,10 @@ impl ScheduledAgentTaskService {
|
|||||||
// 根据 chat_id 自动选择 Session:
|
// 根据 chat_id 自动选择 Session:
|
||||||
// - scheduler/ 开头:使用定时任务专用 Session(独立实例,不与用户消息竞争锁)
|
// - scheduler/ 开头:使用定时任务专用 Session(独立实例,不与用户消息竞争锁)
|
||||||
// - 其他:使用主 Session
|
// - 其他:使用主 Session
|
||||||
let session = self.lifecycle.active_session_for_chat_id(channel_name, chat_id).await?;
|
let session = self
|
||||||
|
.lifecycle
|
||||||
|
.active_session_for_chat_id(channel_name, chat_id)
|
||||||
|
.await?;
|
||||||
let sender_id = options
|
let sender_id = options
|
||||||
.sender_id
|
.sender_id
|
||||||
.clone()
|
.clone()
|
||||||
|
|||||||
@ -2,12 +2,15 @@ use crate::agent::{AgentError, AgentLoop, ContextCompressor, EmittedMessageHandl
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT;
|
use crate::bus::SYSTEM_CONTEXT_SCHEDULED_PROMPT;
|
||||||
use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
|
use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
|
||||||
use crate::providers::StreamDelta;
|
|
||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
use crate::protocol::WsOutbound;
|
use crate::protocol::WsOutbound;
|
||||||
|
use crate::providers::StreamDelta;
|
||||||
use crate::scheduler::ScheduledAgentTaskOptions;
|
use crate::scheduler::ScheduledAgentTaskOptions;
|
||||||
use crate::skills::SkillRuntime;
|
use crate::skills::SkillRuntime;
|
||||||
use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository};
|
use crate::storage::{
|
||||||
|
ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore,
|
||||||
|
SkillEventRepository,
|
||||||
|
};
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
use crate::tools::task::repository::TaskRepository;
|
use crate::tools::task::repository::TaskRepository;
|
||||||
use crate::tools::task::runtime::SubagentRuntime;
|
use crate::tools::task::runtime::SubagentRuntime;
|
||||||
@ -24,8 +27,7 @@ use super::execution::should_display_message_to_user;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::memory_maintenance::{
|
use super::memory_maintenance::{
|
||||||
MemoryMaintenanceMerge, apply_memory_maintenance_output, build_memory_maintenance_plan,
|
MemoryMaintenanceMerge, apply_memory_maintenance_output, build_memory_maintenance_plan,
|
||||||
extract_json_object, is_recoverable_maintenance_llm_error,
|
extract_json_object, is_recoverable_maintenance_llm_error, strip_json_code_fence,
|
||||||
strip_json_code_fence,
|
|
||||||
};
|
};
|
||||||
use super::memory_maintenance::{MemoryMaintenanceScopeResult, MemoryOrganizationOutput};
|
use super::memory_maintenance::{MemoryMaintenanceScopeResult, MemoryOrganizationOutput};
|
||||||
use super::memory_maintenance_coordinator::MemoryMaintenanceCoordinator;
|
use super::memory_maintenance_coordinator::MemoryMaintenanceCoordinator;
|
||||||
@ -125,7 +127,9 @@ impl EmittedMessageHandler for BusToolCallEmitter {
|
|||||||
// Get or create the stream message ID
|
// Get or create the stream message ID
|
||||||
let message_id = {
|
let message_id = {
|
||||||
let mut guard = self.stream_message_id.lock().unwrap();
|
let mut guard = self.stream_message_id.lock().unwrap();
|
||||||
guard.get_or_insert_with(|| Uuid::new_v4().to_string()).clone()
|
guard
|
||||||
|
.get_or_insert_with(|| Uuid::new_v4().to_string())
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Empty content + no reasoning = stream end signal
|
// Empty content + no reasoning = stream end signal
|
||||||
@ -180,7 +184,11 @@ impl BusToolCallEmitter {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| session_id.clone());
|
.unwrap_or_else(|| session_id.clone());
|
||||||
|
|
||||||
let topic_id = self.metadata.get("topic_id").filter(|t| !t.is_empty()).cloned();
|
let topic_id = self
|
||||||
|
.metadata
|
||||||
|
.get("topic_id")
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.cloned();
|
||||||
|
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@ -304,11 +312,7 @@ impl Session {
|
|||||||
skills,
|
skills,
|
||||||
agent_factory,
|
agent_factory,
|
||||||
compressor: ContextCompressor::from_provider_config(&provider_config),
|
compressor: ContextCompressor::from_provider_config(&provider_config),
|
||||||
history: SessionHistory::new(
|
history: SessionHistory::new(channel_name, conversations, skill_events),
|
||||||
channel_name,
|
|
||||||
conversations,
|
|
||||||
skill_events,
|
|
||||||
),
|
|
||||||
store,
|
store,
|
||||||
pending_cancel_tokens: HashMap::new(),
|
pending_cancel_tokens: HashMap::new(),
|
||||||
})
|
})
|
||||||
@ -611,7 +615,9 @@ impl Session {
|
|||||||
// 消费 pending 的取消信号接收端(如果存在)
|
// 消费 pending 的取消信号接收端(如果存在)
|
||||||
// 优先按 topic_id 查找;无 topic 时回退 chat_id
|
// 优先按 topic_id 查找;无 topic 时回退 chat_id
|
||||||
let cancel_token = match &topic_id {
|
let cancel_token = match &topic_id {
|
||||||
Some(tid) => self.pending_cancel_tokens.remove(tid)
|
Some(tid) => self
|
||||||
|
.pending_cancel_tokens
|
||||||
|
.remove(tid)
|
||||||
.or_else(|| self.pending_cancel_tokens.remove(session_chat_id)),
|
.or_else(|| self.pending_cancel_tokens.remove(session_chat_id)),
|
||||||
None => self.pending_cancel_tokens.remove(session_chat_id),
|
None => self.pending_cancel_tokens.remove(session_chat_id),
|
||||||
};
|
};
|
||||||
@ -780,7 +786,11 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取指定 chat 的当前话题(确保 session 存在,自动从数据库恢复)
|
/// 获取指定 chat 的当前话题(确保 session 存在,自动从数据库恢复)
|
||||||
pub async fn get_current_topic(&self, channel_name: &str, chat_id: &str) -> Result<Option<String>, AgentError> {
|
pub async fn get_current_topic(
|
||||||
|
&self,
|
||||||
|
channel_name: &str,
|
||||||
|
chat_id: &str,
|
||||||
|
) -> Result<Option<String>, AgentError> {
|
||||||
self.ensure_session(channel_name).await?;
|
self.ensure_session(channel_name).await?;
|
||||||
if let Some(session) = self.get(channel_name).await {
|
if let Some(session) = self.get(channel_name).await {
|
||||||
let mut guard = session.lock().await;
|
let mut guard = session.lock().await;
|
||||||
@ -788,7 +798,9 @@ impl SessionManager {
|
|||||||
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题
|
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题
|
||||||
if guard.current_topic(chat_id).is_none() {
|
if guard.current_topic(chat_id).is_none() {
|
||||||
let session_id = guard.persistent_session_id(chat_id);
|
let session_id = guard.persistent_session_id(chat_id);
|
||||||
let topics = self.store.list_topics(&session_id)
|
let topics = self
|
||||||
|
.store
|
||||||
|
.list_topics(&session_id)
|
||||||
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
|
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
|
||||||
|
|
||||||
if let Some(latest_topic) = topics.first() {
|
if let Some(latest_topic) = topics.first() {
|
||||||
@ -802,10 +814,7 @@ impl SessionManager {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 数据库中也没有话题,自动创建默认话题
|
// 数据库中也没有话题,自动创建默认话题
|
||||||
let title = format!(
|
let title = format!("话题 {}", chrono::Local::now().format("%m/%d %H:%M"));
|
||||||
"话题 {}",
|
|
||||||
chrono::Local::now().format("%m/%d %H:%M")
|
|
||||||
);
|
|
||||||
match self.store.create_topic(&session_id, &title, None) {
|
match self.store.create_topic(&session_id, &title, None) {
|
||||||
Ok(topic) => {
|
Ok(topic) => {
|
||||||
guard.set_current_topic(chat_id, Some(topic.id.clone()));
|
guard.set_current_topic(chat_id, Some(topic.id.clone()));
|
||||||
@ -845,7 +854,10 @@ impl SessionManager {
|
|||||||
token: tokio::sync::watch::Receiver<()>,
|
token: tokio::sync::watch::Receiver<()>,
|
||||||
) {
|
) {
|
||||||
if let Some(session) = self.get(channel_name).await {
|
if let Some(session) = self.get(channel_name).await {
|
||||||
session.lock().await.set_cancel_receiver(chat_id, topic_id, token);
|
session
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.set_cancel_receiver(chat_id, topic_id, token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -904,7 +916,13 @@ impl SessionManager {
|
|||||||
options: ScheduledAgentTaskOptions,
|
options: ScheduledAgentTaskOptions,
|
||||||
) -> Result<Vec<OutboundMessage>, AgentError> {
|
) -> Result<Vec<OutboundMessage>, AgentError> {
|
||||||
self.scheduled_tasks
|
self.scheduled_tasks
|
||||||
.run(channel_name, session_chat_id, notification_chat_id, prompt, options)
|
.run(
|
||||||
|
channel_name,
|
||||||
|
session_chat_id,
|
||||||
|
notification_chat_id,
|
||||||
|
prompt,
|
||||||
|
options,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1000,12 +1018,16 @@ mod tests {
|
|||||||
|
|
||||||
let first = session.create_user_message("first", Vec::new());
|
let first = session.create_user_message("first", Vec::new());
|
||||||
let first_id = first.id.clone();
|
let first_id = first.id.clone();
|
||||||
session.append_persisted_message("chat-1", Some(&topic_id), first).unwrap();
|
session
|
||||||
|
.append_persisted_message("chat-1", Some(&topic_id), first)
|
||||||
|
.unwrap();
|
||||||
assert!(session.is_latest_user_message(&topic_id, &first_id));
|
assert!(session.is_latest_user_message(&topic_id, &first_id));
|
||||||
|
|
||||||
let second = session.create_user_message("second", Vec::new());
|
let second = session.create_user_message("second", Vec::new());
|
||||||
let second_id = second.id.clone();
|
let second_id = second.id.clone();
|
||||||
session.append_persisted_message("chat-1", Some(&topic_id), second).unwrap();
|
session
|
||||||
|
.append_persisted_message("chat-1", Some(&topic_id), second)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(!session.is_latest_user_message(&topic_id, &first_id));
|
assert!(!session.is_latest_user_message(&topic_id, &first_id));
|
||||||
assert!(session.is_latest_user_message(&topic_id, &second_id));
|
assert!(session.is_latest_user_message(&topic_id, &second_id));
|
||||||
@ -1052,9 +1074,15 @@ mod tests {
|
|||||||
|
|
||||||
let first = session.create_user_message("first", Vec::new());
|
let first = session.create_user_message("first", Vec::new());
|
||||||
let first_id = first.id.clone();
|
let first_id = first.id.clone();
|
||||||
session.append_persisted_message("chat-1", Some(&topic_id), first).unwrap();
|
|
||||||
session
|
session
|
||||||
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::assistant("answer-1"))
|
.append_persisted_message("chat-1", Some(&topic_id), first)
|
||||||
|
.unwrap();
|
||||||
|
session
|
||||||
|
.append_persisted_message(
|
||||||
|
"chat-1",
|
||||||
|
Some(&topic_id),
|
||||||
|
ChatMessage::assistant("answer-1"),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let second = session.create_user_message("second", Vec::new());
|
let second = session.create_user_message("second", Vec::new());
|
||||||
@ -1062,7 +1090,11 @@ mod tests {
|
|||||||
.append_persisted_message("chat-1", Some(&topic_id), second.clone())
|
.append_persisted_message("chat-1", Some(&topic_id), second.clone())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
session
|
session
|
||||||
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::assistant("answer-2"))
|
.append_persisted_message(
|
||||||
|
"chat-1",
|
||||||
|
Some(&topic_id),
|
||||||
|
ChatMessage::assistant("answer-2"),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let preserved_messages = session.get_history(&topic_id).unwrap().clone();
|
let preserved_messages = session.get_history(&topic_id).unwrap().clone();
|
||||||
@ -1235,7 +1267,15 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let outbound = session_manager
|
let outbound = session_manager
|
||||||
.handle_message("test-channel", "user-1", "chat-1", "hello", Vec::new(), None, None)
|
.handle_message(
|
||||||
|
"test-channel",
|
||||||
|
"user-1",
|
||||||
|
"chat-1",
|
||||||
|
"hello",
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -1733,7 +1773,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_run_memory_maintenance_for_all_scopes_scans_all_scopes_even_without_recent_updates() {
|
async fn test_run_memory_maintenance_for_all_scopes_scans_all_scopes_even_without_recent_updates()
|
||||||
|
{
|
||||||
let mock_response_content = serde_json::to_string(&json!({
|
let mock_response_content = serde_json::to_string(&json!({
|
||||||
"user_facts": ["用户在做AI产品"],
|
"user_facts": ["用户在做AI产品"],
|
||||||
"preferences": [],
|
"preferences": [],
|
||||||
@ -1983,11 +2024,18 @@ mod tests {
|
|||||||
|
|
||||||
let all_memories = store.list_memories_for_scope("user", scope_key).unwrap();
|
let all_memories = store.list_memories_for_scope("user", scope_key).unwrap();
|
||||||
// 过滤掉 _meta 记录
|
// 过滤掉 _meta 记录
|
||||||
let user_memories: Vec<_> = all_memories.iter().filter(|m| m.namespace != "_meta").collect();
|
let user_memories: Vec<_> = all_memories
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.namespace != "_meta")
|
||||||
|
.collect();
|
||||||
// 合并 2 条为 1 条,删除 1 条,7 - 2 + 1 = 5 条
|
// 合并 2 条为 1 条,删除 1 条,7 - 2 + 1 = 5 条
|
||||||
assert_eq!(user_memories.len(), 5);
|
assert_eq!(user_memories.len(), 5);
|
||||||
// 验证合并后的记忆存在
|
// 验证合并后的记忆存在
|
||||||
assert!(user_memories.iter().any(|m| m.namespace == "user" && m.memory_key == "work"));
|
assert!(
|
||||||
|
user_memories
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.namespace == "user" && m.memory_key == "work")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -2010,19 +2058,16 @@ mod tests {
|
|||||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||||
let bus = MessageBus::new(4);
|
let bus = MessageBus::new(4);
|
||||||
let emitter =
|
let emitter =
|
||||||
BusToolCallEmitter::new(
|
BusToolCallEmitter::new(bus.clone(), "test-channel", "chat-1", HashMap::new(), store);
|
||||||
bus.clone(),
|
|
||||||
"test-channel",
|
|
||||||
"chat-1",
|
|
||||||
HashMap::new(),
|
|
||||||
store,
|
|
||||||
);
|
|
||||||
|
|
||||||
emitter
|
emitter
|
||||||
.handle(ChatMessage::tool("call-1", "calculator", "2"))
|
.handle(ChatMessage::tool("call-1", "calculator", "2"))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let msg = tokio::time::timeout(std::time::Duration::from_millis(500), bus.consume_outbound())
|
let msg = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(500),
|
||||||
|
bus.consume_outbound(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.expect("timeout waiting for outbound message")
|
.expect("timeout waiting for outbound message")
|
||||||
.expect("bus outbound closed");
|
.expect("bus outbound closed");
|
||||||
@ -2111,7 +2156,11 @@ mod tests {
|
|||||||
|
|
||||||
for turn in 0..100 {
|
for turn in 0..100 {
|
||||||
session
|
session
|
||||||
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::user(format!("user-{turn}")))
|
.append_persisted_message(
|
||||||
|
"chat-1",
|
||||||
|
Some(&topic_id),
|
||||||
|
ChatMessage::user(format!("user-{turn}")),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2188,7 +2237,11 @@ mod tests {
|
|||||||
|
|
||||||
for turn in 0..100 {
|
for turn in 0..100 {
|
||||||
session
|
session
|
||||||
.append_persisted_message("chat-1", Some(&topic_id), ChatMessage::user(format!("user-{turn}")))
|
.append_persisted_message(
|
||||||
|
"chat-1",
|
||||||
|
Some(&topic_id),
|
||||||
|
ChatMessage::user(format!("user-{turn}")),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,9 @@ impl SessionHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_or_create_history(&mut self, topic_id: &str) -> &mut Vec<ChatMessage> {
|
pub(crate) fn get_or_create_history(&mut self, topic_id: &str) -> &mut Vec<ChatMessage> {
|
||||||
self.topic_histories.entry(topic_id.to_string()).or_default()
|
self.topic_histories
|
||||||
|
.entry(topic_id.to_string())
|
||||||
|
.or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_history(&self, topic_id: &str) -> Option<&Vec<ChatMessage>> {
|
pub(crate) fn get_history(&self, topic_id: &str) -> Option<&Vec<ChatMessage>> {
|
||||||
|
|||||||
@ -52,9 +52,12 @@ impl SessionLifecycleService {
|
|||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
chat_id: &str,
|
chat_id: &str,
|
||||||
) -> Result<Arc<Mutex<Session>>, AgentError> {
|
) -> Result<Arc<Mutex<Session>>, AgentError> {
|
||||||
self.session_pool.ensure_session_for_chat_id(channel_name, chat_id).await?;
|
self.session_pool
|
||||||
|
.ensure_session_for_chat_id(channel_name, chat_id)
|
||||||
|
.await?;
|
||||||
self.touch(channel_name).await;
|
self.touch(channel_name).await;
|
||||||
self.session_pool.get_for_chat_id(channel_name, chat_id)
|
self.session_pool
|
||||||
|
.get_for_chat_id(channel_name, chat_id)
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| AgentError::Other("Session not found".to_string()))
|
.ok_or_else(|| AgentError::Other("Session not found".to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,7 +122,7 @@ mod tests {
|
|||||||
// 使用临时目录确保跨平台兼容
|
// 使用临时目录确保跨平台兼容
|
||||||
attachments: vec![MediaItem::new(
|
attachments: vec![MediaItem::new(
|
||||||
&std::env::temp_dir().join("demo.png").display().to_string(),
|
&std::env::temp_dir().join("demo.png").display().to_string(),
|
||||||
"image"
|
"image",
|
||||||
)],
|
)],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -49,7 +49,10 @@ impl SessionPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 确保定时任务专用 Session 存在
|
/// 确保定时任务专用 Session 存在
|
||||||
pub(crate) async fn ensure_scheduler_session(&self, channel_name: &str) -> Result<(), AgentError> {
|
pub(crate) async fn ensure_scheduler_session(
|
||||||
|
&self,
|
||||||
|
channel_name: &str,
|
||||||
|
) -> Result<(), AgentError> {
|
||||||
self.ensure_session_internal(channel_name, true).await
|
self.ensure_session_internal(channel_name, true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,7 +62,11 @@ impl SessionPool {
|
|||||||
/// session 创建(含配置加载、agent 工厂构造),再次持锁插入并处理竞态。
|
/// session 创建(含配置加载、agent 工厂构造),再次持锁插入并处理竞态。
|
||||||
/// 避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的
|
/// 避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的
|
||||||
/// session 访问串行化。
|
/// session 访问串行化。
|
||||||
async fn ensure_session_internal(&self, channel_name: &str, is_scheduler: bool) -> Result<(), AgentError> {
|
async fn ensure_session_internal(
|
||||||
|
&self,
|
||||||
|
channel_name: &str,
|
||||||
|
is_scheduler: bool,
|
||||||
|
) -> Result<(), AgentError> {
|
||||||
// Fast path: 已存在直接返回(短暂持锁)
|
// Fast path: 已存在直接返回(短暂持锁)
|
||||||
{
|
{
|
||||||
let inner = self.inner.lock().await;
|
let inner = self.inner.lock().await;
|
||||||
@ -109,14 +116,26 @@ impl SessionPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 获取定时任务专用 Session
|
/// 获取定时任务专用 Session
|
||||||
pub(crate) async fn get_scheduler_session(&self, channel_name: &str) -> Option<Arc<Mutex<Session>>> {
|
pub(crate) async fn get_scheduler_session(
|
||||||
self.inner.lock().await.scheduler_sessions.get(channel_name).cloned()
|
&self,
|
||||||
|
channel_name: &str,
|
||||||
|
) -> Option<Arc<Mutex<Session>>> {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.scheduler_sessions
|
||||||
|
.get(channel_name)
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据 chat_id 自动选择 Session
|
/// 根据 chat_id 自动选择 Session
|
||||||
/// - scheduler/ 开头:返回定时任务专用 Session
|
/// - scheduler/ 开头:返回定时任务专用 Session
|
||||||
/// - 其他:返回主 Session
|
/// - 其他:返回主 Session
|
||||||
pub(crate) async fn get_for_chat_id(&self, channel_name: &str, chat_id: &str) -> Option<Arc<Mutex<Session>>> {
|
pub(crate) async fn get_for_chat_id(
|
||||||
|
&self,
|
||||||
|
channel_name: &str,
|
||||||
|
chat_id: &str,
|
||||||
|
) -> Option<Arc<Mutex<Session>>> {
|
||||||
if is_scheduler_chat_id(chat_id) {
|
if is_scheduler_chat_id(chat_id) {
|
||||||
self.get_scheduler_session(channel_name).await
|
self.get_scheduler_session(channel_name).await
|
||||||
} else {
|
} else {
|
||||||
@ -125,7 +144,11 @@ impl SessionPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 确保 Session 存在(根据 chat_id 自动选择)
|
/// 确保 Session 存在(根据 chat_id 自动选择)
|
||||||
pub(crate) async fn ensure_session_for_chat_id(&self, channel_name: &str, chat_id: &str) -> Result<(), AgentError> {
|
pub(crate) async fn ensure_session_for_chat_id(
|
||||||
|
&self,
|
||||||
|
channel_name: &str,
|
||||||
|
chat_id: &str,
|
||||||
|
) -> Result<(), AgentError> {
|
||||||
if is_scheduler_chat_id(chat_id) {
|
if is_scheduler_chat_id(chat_id) {
|
||||||
self.ensure_scheduler_session(channel_name).await
|
self.ensure_scheduler_session(channel_name).await
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{header, Response, StatusCode, Uri},
|
http::{Response, StatusCode, Uri, header},
|
||||||
};
|
};
|
||||||
use rust_embed::RustEmbed;
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
@ -16,11 +16,7 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
|
|||||||
let path = uri.path().trim_start_matches('/');
|
let path = uri.path().trim_start_matches('/');
|
||||||
|
|
||||||
// 处理根路径,返回 index.html
|
// 处理根路径,返回 index.html
|
||||||
let path = if path.is_empty() {
|
let path = if path.is_empty() { "index.html" } else { path };
|
||||||
"index.html"
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
};
|
|
||||||
|
|
||||||
match StaticAssets::get(path) {
|
match StaticAssets::get(path) {
|
||||||
Some(content) => {
|
Some(content) => {
|
||||||
|
|||||||
@ -6,13 +6,14 @@ use tokio::sync::RwLock;
|
|||||||
use crate::config::TaskConfig;
|
use crate::config::TaskConfig;
|
||||||
use crate::mcp::McpClientManager;
|
use crate::mcp::McpClientManager;
|
||||||
use crate::skills::SkillRuntime;
|
use crate::skills::SkillRuntime;
|
||||||
use crate::storage::{MemoryRepository, SchedulerJobRepository, SkillEventRepository, TodoRepository};
|
use crate::storage::{
|
||||||
|
MemoryRepository, SchedulerJobRepository, SkillEventRepository, TodoRepository,
|
||||||
|
};
|
||||||
use crate::tools::todo_write::TodoItem;
|
use crate::tools::todo_write::TodoItem;
|
||||||
use crate::tools::{
|
use crate::tools::{
|
||||||
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
|
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, HttpRequestTool,
|
||||||
HttpRequestTool, MemoryManageTool, MemorySearchTool,
|
MemoryManageTool, MemorySearchTool, SchedulerManageTool, SessionMessageSender, SessionSendTool,
|
||||||
SchedulerManageTool, SessionMessageSender, SessionSendTool, ShellSessionManager,
|
ShellSessionManager, SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
|
||||||
SkillActivateTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
|
|
||||||
TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool,
|
TodoReadTool, TodoWriteTool, ToolRegistry, WebFetchTool,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -72,18 +73,12 @@ impl ToolRegistryFactory {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn with_subagent_runtime(
|
pub(crate) fn with_subagent_runtime(mut self, runtime: Arc<dyn SubAgentRuntime>) -> Self {
|
||||||
mut self,
|
|
||||||
runtime: Arc<dyn SubAgentRuntime>,
|
|
||||||
) -> Self {
|
|
||||||
self.subagent_runtime = Some(runtime);
|
self.subagent_runtime = Some(runtime);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn with_mcp_manager(
|
pub(crate) fn with_mcp_manager(mut self, manager: Arc<McpClientManager>) -> Self {
|
||||||
mut self,
|
|
||||||
manager: Arc<McpClientManager>,
|
|
||||||
) -> Self {
|
|
||||||
self.mcp_manager = Some(manager);
|
self.mcp_manager = Some(manager);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@ -118,8 +113,14 @@ impl ToolRegistryFactory {
|
|||||||
}
|
}
|
||||||
if self.is_enabled("todo_write") {
|
if self.is_enabled("todo_write") {
|
||||||
if let Some(ref state) = self.todo_state {
|
if let Some(ref state) = self.todo_state {
|
||||||
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
|
registry.register(TodoWriteTool::new(
|
||||||
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
|
state.clone(),
|
||||||
|
self.todo_repository.clone(),
|
||||||
|
));
|
||||||
|
registry.register(TodoReadTool::new(
|
||||||
|
state.clone(),
|
||||||
|
self.todo_repository.clone(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.is_enabled("session_send") {
|
if self.is_enabled("session_send") {
|
||||||
@ -226,8 +227,14 @@ impl ToolRegistryFactory {
|
|||||||
// Todo 追踪工具
|
// Todo 追踪工具
|
||||||
if self.is_enabled("todo_write") {
|
if self.is_enabled("todo_write") {
|
||||||
if let Some(ref state) = self.todo_state {
|
if let Some(ref state) = self.todo_state {
|
||||||
registry.register(TodoWriteTool::new(state.clone(), self.todo_repository.clone()));
|
registry.register(TodoWriteTool::new(
|
||||||
registry.register(TodoReadTool::new(state.clone(), self.todo_repository.clone()));
|
state.clone(),
|
||||||
|
self.todo_repository.clone(),
|
||||||
|
));
|
||||||
|
registry.register(TodoReadTool::new(
|
||||||
|
state.clone(),
|
||||||
|
self.todo_repository.clone(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,16 +10,16 @@ use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
|
|||||||
use crate::command::handlers::help::HelpCommandHandler;
|
use crate::command::handlers::help::HelpCommandHandler;
|
||||||
use crate::command::handlers::list_channels::ListChannelsCommandHandler;
|
use crate::command::handlers::list_channels::ListChannelsCommandHandler;
|
||||||
use crate::command::handlers::list_memories::ListMemoriesCommandHandler;
|
use crate::command::handlers::list_memories::ListMemoriesCommandHandler;
|
||||||
use crate::command::handlers::list_skills::ListSkillsCommandHandler;
|
|
||||||
use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
|
use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
|
||||||
use crate::command::handlers::list_todos::ListTodosCommandHandler;
|
|
||||||
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
|
|
||||||
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
|
||||||
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
|
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
|
||||||
|
use crate::command::handlers::list_skills::ListSkillsCommandHandler;
|
||||||
|
use crate::command::handlers::list_todos::ListTodosCommandHandler;
|
||||||
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
|
use crate::command::handlers::list_topics::ListTopicsCommandHandler;
|
||||||
use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler;
|
use crate::command::handlers::load_chat_messages::LoadChatMessagesCommandHandler;
|
||||||
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
|
use crate::command::handlers::load_task_messages::LoadTaskMessagesCommandHandler;
|
||||||
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
|
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
|
||||||
|
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
|
||||||
use crate::command::handlers::rename_topic::RenameTopicCommandHandler;
|
use crate::command::handlers::rename_topic::RenameTopicCommandHandler;
|
||||||
use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
use crate::command::handlers::save_session::SaveSessionCommandHandler;
|
||||||
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
|
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
|
||||||
@ -27,7 +27,7 @@ use crate::command::handlers::session::SessionCommandHandler;
|
|||||||
use crate::command::handlers::stop_execution::StopExecutionCommandHandler;
|
use crate::command::handlers::stop_execution::StopExecutionCommandHandler;
|
||||||
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
|
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
|
||||||
use crate::gateway::agent_factory::build_system_prompt_provider;
|
use crate::gateway::agent_factory::build_system_prompt_provider;
|
||||||
use crate::protocol::{WsInbound, WsOutbound, MediaSummary, parse_inbound, serialize_outbound};
|
use crate::protocol::{MediaSummary, WsInbound, WsOutbound, parse_inbound, serialize_outbound};
|
||||||
use crate::storage::persistent_session_id;
|
use crate::storage::persistent_session_id;
|
||||||
use crate::tools::task::repository::TaskRepository;
|
use crate::tools::task::repository::TaskRepository;
|
||||||
use crate::tools::task::types::TaskSessionState;
|
use crate::tools::task::types::TaskSessionState;
|
||||||
@ -68,7 +68,9 @@ fn build_media_filename(media_type: &str, file_name: Option<&str>) -> String {
|
|||||||
|
|
||||||
/// Process attachments with base64 content: save to local file and return MediaItem with correct path
|
/// Process attachments with base64 content: save to local file and return MediaItem with correct path
|
||||||
/// Keeps content_base64 for frontend display/download
|
/// Keeps content_base64 for frontend display/download
|
||||||
fn process_attachments_with_base64(attachments: Vec<MediaSummary>) -> Result<Vec<MediaItem>, AgentError> {
|
fn process_attachments_with_base64(
|
||||||
|
attachments: Vec<MediaSummary>,
|
||||||
|
) -> Result<Vec<MediaItem>, AgentError> {
|
||||||
if attachments.is_empty() {
|
if attachments.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@ -82,15 +84,16 @@ fn process_attachments_with_base64(attachments: Vec<MediaSummary>) -> Result<Vec
|
|||||||
.map(|att| {
|
.map(|att| {
|
||||||
// If content_base64 exists, save to file and update path
|
// If content_base64 exists, save to file and update path
|
||||||
if let Some(base64_content) = &att.content_base64 {
|
if let Some(base64_content) = &att.content_base64 {
|
||||||
let decoded = STANDARD
|
let decoded = STANDARD.decode(base64_content).map_err(|error| {
|
||||||
.decode(base64_content)
|
AgentError::Other(format!("Failed to decode base64: {}", error))
|
||||||
.map_err(|error| AgentError::Other(format!("Failed to decode base64: {}", error)))?;
|
})?;
|
||||||
|
|
||||||
let filename = build_media_filename(&att.media_type, att.file_name.as_deref());
|
let filename = build_media_filename(&att.media_type, att.file_name.as_deref());
|
||||||
let file_path = media_dir.join(&filename);
|
let file_path = media_dir.join(&filename);
|
||||||
|
|
||||||
std::fs::write(&file_path, decoded)
|
std::fs::write(&file_path, decoded).map_err(|error| {
|
||||||
.map_err(|error| AgentError::Other(format!("Failed to write media file: {}", error)))?;
|
AgentError::Other(format!("Failed to write media file: {}", error))
|
||||||
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
filename = %filename,
|
filename = %filename,
|
||||||
@ -136,10 +139,8 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
|||||||
let store = state.session_manager.store();
|
let store = state.session_manager.store();
|
||||||
|
|
||||||
// 1. 查询 websocket 和 cli 两个通道的 Sessions(兼容旧版本 cli 通道创建的会话)
|
// 1. 查询 websocket 和 cli 两个通道的 Sessions(兼容旧版本 cli 通道创建的会话)
|
||||||
let mut websocket_sessions = store.list_sessions("websocket", false)
|
let mut websocket_sessions = store.list_sessions("websocket", false).unwrap_or_default();
|
||||||
.unwrap_or_default();
|
let cli_channel_sessions = store.list_sessions("cli", false).unwrap_or_default();
|
||||||
let cli_channel_sessions = store.list_sessions("cli", false)
|
|
||||||
.unwrap_or_default();
|
|
||||||
websocket_sessions.extend(cli_channel_sessions);
|
websocket_sessions.extend(cli_channel_sessions);
|
||||||
websocket_sessions.sort_by_key(|s| -(s.last_active_at));
|
websocket_sessions.sort_by_key(|s| -(s.last_active_at));
|
||||||
|
|
||||||
@ -180,9 +181,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
|||||||
|
|
||||||
// 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道)
|
// 连接建立后立即发送通道列表(合并 websocket + ChannelManager 动态通道)
|
||||||
let channels = state.channel_manager.build_channel_list().await;
|
let channels = state.channel_manager.build_channel_list().await;
|
||||||
let _ = sender
|
let _ = sender.send(WsOutbound::ChannelList { channels }).await;
|
||||||
.send(WsOutbound::ChannelList { channels })
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 3. 发送合并后的 Session 列表(已在上面合并了 websocket + cli 通道)
|
// 3. 发送合并后的 Session 列表(已在上面合并了 websocket + cli 通道)
|
||||||
// 如果刚创建了新会话,确保它也在列表中
|
// 如果刚创建了新会话,确保它也在列表中
|
||||||
@ -304,7 +303,6 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
|
|||||||
tracing::info!(session_id = %runtime_session_id, current_session_id = %current_session_id, "CLI session ended");
|
tracing::info!(session_id = %runtime_session_id, current_session_id = %current_session_id, "CLI session ended");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async fn handle_inbound(
|
async fn handle_inbound(
|
||||||
state: &Arc<GatewayState>,
|
state: &Arc<GatewayState>,
|
||||||
sender: &mpsc::Sender<WsOutbound>,
|
sender: &mpsc::Sender<WsOutbound>,
|
||||||
@ -394,7 +392,11 @@ async fn handle_inbound(
|
|||||||
let store = state.session_manager.store();
|
let store = state.session_manager.store();
|
||||||
let skills = state.session_manager.skills();
|
let skills = state.session_manager.skills();
|
||||||
let skills_for_handler = skills.clone();
|
let skills_for_handler = skills.clone();
|
||||||
let provider_config = state.config.read().await.get_provider_config("default")
|
let provider_config = state
|
||||||
|
.config
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get_provider_config("default")
|
||||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||||
let prompt_repository = state.session_manager.store().clone();
|
let prompt_repository = state.session_manager.store().clone();
|
||||||
|
|
||||||
@ -417,9 +419,13 @@ async fn handle_inbound(
|
|||||||
// 注册 list_sessions 处理器
|
// 注册 list_sessions 处理器
|
||||||
router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
|
router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
|
||||||
// 注册 list_sessions_by_channel 处理器
|
// 注册 list_sessions_by_channel 处理器
|
||||||
router.register(Box::new(ListSessionsByChannelCommandHandler::new(store.clone())));
|
router.register(Box::new(ListSessionsByChannelCommandHandler::new(
|
||||||
|
store.clone(),
|
||||||
|
)));
|
||||||
// 注册 list_channels 处理器
|
// 注册 list_channels 处理器
|
||||||
router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(state.channel_manager.clone()))));
|
router.register(Box::new(ListChannelsCommandHandler::new(Arc::new(
|
||||||
|
state.channel_manager.clone(),
|
||||||
|
))));
|
||||||
// 注册 list_topics 处理器
|
// 注册 list_topics 处理器
|
||||||
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
|
router.register(Box::new(ListTopicsCommandHandler::new(store.clone())));
|
||||||
// 注册 switch_topic 处理器
|
// 注册 switch_topic 处理器
|
||||||
@ -460,7 +466,9 @@ async fn handle_inbound(
|
|||||||
let metadata = router.metadata_arc();
|
let metadata = router.metadata_arc();
|
||||||
router.register(Box::new(HelpCommandHandler::new(metadata)));
|
router.register(Box::new(HelpCommandHandler::new(metadata)));
|
||||||
// 注册 list_scheduler_jobs 处理器
|
// 注册 list_scheduler_jobs 处理器
|
||||||
router.register(Box::new(ListSchedulerJobsCommandHandler::new(store.clone())));
|
router.register(Box::new(ListSchedulerJobsCommandHandler::new(
|
||||||
|
store.clone(),
|
||||||
|
)));
|
||||||
// 注册 list_memories 处理器
|
// 注册 list_memories 处理器
|
||||||
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
|
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
|
||||||
// 注册 list_skills 处理器
|
// 注册 list_skills 处理器
|
||||||
@ -524,51 +532,95 @@ async fn handle_inbound(
|
|||||||
*current_topic_id = Some(topic_id.clone());
|
*current_topic_id = Some(topic_id.clone());
|
||||||
|
|
||||||
// 加载并发送该话题的历史消息
|
// 加载并发送该话题的历史消息
|
||||||
if let Err(e) = send_topic_history(&store, current_session_id, topic_id, sender, &state.task_repository).await {
|
if let Err(e) = send_topic_history(
|
||||||
|
&store,
|
||||||
|
current_session_id,
|
||||||
|
topic_id,
|
||||||
|
sender,
|
||||||
|
&state.task_repository,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
|
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send topic history");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 加载子智能体任务消息
|
// 加载子智能体任务消息
|
||||||
if let Some(task_session_id) = response.metadata.get("task_session_id") {
|
if let Some(task_session_id) = response.metadata.get("task_session_id") {
|
||||||
// 提前提取 task_id,用于给历史消息打标记
|
// 提前提取 task_id,用于给历史消息打标记
|
||||||
let task_id = response.metadata.get("task_id").cloned().unwrap_or_default();
|
let task_id = response
|
||||||
if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone()), Some(&state.task_repository)).await {
|
.metadata
|
||||||
|
.get("task_id")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Err(e) = send_task_messages(
|
||||||
|
&store,
|
||||||
|
task_session_id,
|
||||||
|
sender,
|
||||||
|
Some(task_id.clone()),
|
||||||
|
Some(&state.task_repository),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
|
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送 TaskMessagesLoaded 元数据
|
// 发送 TaskMessagesLoaded 元数据
|
||||||
let description = response.metadata.get("task_description").cloned().unwrap_or_default();
|
let description = response
|
||||||
let subagent_type = response.metadata.get("task_subagent_type").cloned().unwrap_or_default();
|
.metadata
|
||||||
let status = response.metadata.get("task_status").cloned().unwrap_or_default();
|
.get("task_description")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let subagent_type = response
|
||||||
|
.metadata
|
||||||
|
.get("task_subagent_type")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let status = response
|
||||||
|
.metadata
|
||||||
|
.get("task_status")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
let summary = response.metadata.get("task_summary").cloned();
|
let summary = response.metadata.get("task_summary").cloned();
|
||||||
|
|
||||||
let _ = sender.send(WsOutbound::TaskMessagesLoaded {
|
let _ = sender
|
||||||
|
.send(WsOutbound::TaskMessagesLoaded {
|
||||||
task_id,
|
task_id,
|
||||||
description,
|
description,
|
||||||
subagent_type,
|
subagent_type,
|
||||||
status,
|
status,
|
||||||
summary,
|
summary,
|
||||||
}).await;
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理定时任务列表
|
// 处理定时任务列表
|
||||||
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") {
|
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") {
|
||||||
if let Ok(jobs) = serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json) {
|
if let Ok(jobs) =
|
||||||
|
serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json)
|
||||||
|
{
|
||||||
let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await;
|
let _ = sender.send(WsOutbound::SchedulerJobList { jobs }).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理技能列表
|
// 处理技能列表
|
||||||
if let Some(skills_json) = response.metadata.get("skills") {
|
if let Some(skills_json) = response.metadata.get("skills") {
|
||||||
if let Ok(skills) = serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json) {
|
if let Ok(skills) =
|
||||||
|
serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json)
|
||||||
|
{
|
||||||
let _ = sender.send(WsOutbound::SkillList { skills }).await;
|
let _ = sender.send(WsOutbound::SkillList { skills }).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 Todo 列表
|
// 处理 Todo 列表
|
||||||
if let Some(todos_json) = response.metadata.get("todos") {
|
if let Some(todos_json) = response.metadata.get("todos") {
|
||||||
if let Ok(todos) = serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json) {
|
if let Ok(todos) =
|
||||||
let scope_key = response.metadata.get("todos_scope_key").cloned().unwrap_or_default();
|
serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
|
||||||
|
{
|
||||||
|
let scope_key = response
|
||||||
|
.metadata
|
||||||
|
.get("todos_scope_key")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
tracing::info!(todo_count = todos.len(), %scope_key, "list_todos command response");
|
tracing::info!(todo_count = todos.len(), %scope_key, "list_todos command response");
|
||||||
let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await;
|
let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await;
|
||||||
}
|
}
|
||||||
@ -576,14 +628,18 @@ async fn handle_inbound(
|
|||||||
|
|
||||||
// 处理记忆列表
|
// 处理记忆列表
|
||||||
if let Some(memories_json) = response.metadata.get("memories") {
|
if let Some(memories_json) = response.metadata.get("memories") {
|
||||||
if let Ok(memories) = serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json) {
|
if let Ok(memories) =
|
||||||
|
serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json)
|
||||||
|
{
|
||||||
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记忆 CRUD 后自动刷新列表
|
// 记忆 CRUD 后自动刷新列表
|
||||||
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") {
|
if response.metadata.get("memory_updated").map(|v| v.as_str()) == Some("true") {
|
||||||
if let Ok(records) = store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY) {
|
if let Ok(records) =
|
||||||
|
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
|
||||||
|
{
|
||||||
let memories: Vec<crate::protocol::MemorySummary> = records
|
let memories: Vec<crate::protocol::MemorySummary> = records
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|m| m.namespace != "_meta")
|
.filter(|m| m.namespace != "_meta")
|
||||||
@ -602,15 +658,17 @@ async fn handle_inbound(
|
|||||||
|
|
||||||
// 处理加载聊天消息请求
|
// 处理加载聊天消息请求
|
||||||
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
||||||
let load_chat_channel = response.metadata.get("load_chat_channel")
|
let load_chat_channel = response
|
||||||
|
.metadata
|
||||||
|
.get("load_chat_channel")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
// session_id = "{channel}:{chat_id}" (cli channel 例外)
|
// session_id = "{channel}:{chat_id}" (cli channel 例外)
|
||||||
let session_id = crate::storage::persistent_session_id(
|
let session_id =
|
||||||
&load_chat_channel,
|
crate::storage::persistent_session_id(&load_chat_channel, load_chat_id);
|
||||||
load_chat_id,
|
if let Err(e) =
|
||||||
);
|
send_task_messages(&store, &session_id, sender, None, None).await
|
||||||
if let Err(e) = send_task_messages(&store, &session_id, sender, None, None).await {
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
channel = %load_chat_channel,
|
channel = %load_chat_channel,
|
||||||
@ -623,12 +681,22 @@ async fn handle_inbound(
|
|||||||
|
|
||||||
if current_topic_id.is_none() {
|
if current_topic_id.is_none() {
|
||||||
if let Some(topics_json) = response.metadata.get("topics") {
|
if let Some(topics_json) = response.metadata.get("topics") {
|
||||||
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(topics_json) {
|
match serde_json::from_str::<Vec<crate::protocol::TopicSummary>>(
|
||||||
|
topics_json,
|
||||||
|
) {
|
||||||
Ok(topics) => {
|
Ok(topics) => {
|
||||||
if let Some(first_topic) = topics.first() {
|
if let Some(first_topic) = topics.first() {
|
||||||
let topic_id = first_topic.topic_id.clone();
|
let topic_id = first_topic.topic_id.clone();
|
||||||
*current_topic_id = Some(topic_id.clone());
|
*current_topic_id = Some(topic_id.clone());
|
||||||
if let Err(e) = send_topic_history(&store, current_session_id, &topic_id, sender, &state.task_repository).await {
|
if let Err(e) = send_topic_history(
|
||||||
|
&store,
|
||||||
|
current_session_id,
|
||||||
|
&topic_id,
|
||||||
|
sender,
|
||||||
|
&state.task_repository,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
|
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to send initial topic history");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -865,10 +933,15 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
|||||||
let media_type = mime_type
|
let media_type = mime_type
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
if m.starts_with("image/") { "image" }
|
if m.starts_with("image/") {
|
||||||
else if m.starts_with("audio/") { "audio" }
|
"image"
|
||||||
else if m.starts_with("video/") { "video" }
|
} else if m.starts_with("audio/") {
|
||||||
else { "file" }
|
"audio"
|
||||||
|
} else if m.starts_with("video/") {
|
||||||
|
"video"
|
||||||
|
} else {
|
||||||
|
"file"
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.unwrap_or("file");
|
.unwrap_or("file");
|
||||||
|
|
||||||
@ -892,7 +965,8 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
|||||||
"assistant" => {
|
"assistant" => {
|
||||||
if let Some(tool_calls) = &msg.tool_calls {
|
if let Some(tool_calls) = &msg.tool_calls {
|
||||||
let mut outbound = Vec::new();
|
let mut outbound = Vec::new();
|
||||||
let has_content_or_reasoning = !msg.content.trim().is_empty() || msg.reasoning_content.is_some();
|
let has_content_or_reasoning =
|
||||||
|
!msg.content.trim().is_empty() || msg.reasoning_content.is_some();
|
||||||
if has_content_or_reasoning {
|
if has_content_or_reasoning {
|
||||||
outbound.push(WsOutbound::AssistantResponse {
|
outbound.push(WsOutbound::AssistantResponse {
|
||||||
id: msg.id.clone(),
|
id: msg.id.clone(),
|
||||||
@ -907,7 +981,11 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复
|
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复
|
||||||
let tc_reasoning = if has_content_or_reasoning { None } else { msg.reasoning_content.clone() };
|
let tc_reasoning = if has_content_or_reasoning {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
msg.reasoning_content.clone()
|
||||||
|
};
|
||||||
for tool_call in tool_calls {
|
for tool_call in tool_calls {
|
||||||
outbound.push(WsOutbound::ToolCall {
|
outbound.push(WsOutbound::ToolCall {
|
||||||
id: tool_call.id.clone(),
|
id: tool_call.id.clone(),
|
||||||
@ -940,10 +1018,16 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"tool" => {
|
"tool" => {
|
||||||
let tool_state = msg.tool_state.as_ref().unwrap_or(&ToolMessageState::Completed);
|
let tool_state = msg
|
||||||
|
.tool_state
|
||||||
|
.as_ref()
|
||||||
|
.unwrap_or(&ToolMessageState::Completed);
|
||||||
match tool_state {
|
match tool_state {
|
||||||
ToolMessageState::Completed => vec![WsOutbound::ToolResult {
|
ToolMessageState::Completed => vec![WsOutbound::ToolResult {
|
||||||
id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
id: msg
|
||||||
|
.tool_call_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||||
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
|
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
|
||||||
tool_name: msg.tool_name.clone().unwrap_or_default(),
|
tool_name: msg.tool_name.clone().unwrap_or_default(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
@ -954,7 +1038,10 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
|||||||
timestamp: Some(msg.timestamp / 1000),
|
timestamp: Some(msg.timestamp / 1000),
|
||||||
}],
|
}],
|
||||||
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
|
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
|
||||||
id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
id: msg
|
||||||
|
.tool_call_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||||
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
|
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
|
||||||
tool_name: msg.tool_name.clone().unwrap_or_default(),
|
tool_name: msg.tool_name.clone().unwrap_or_default(),
|
||||||
content: msg.content.clone(),
|
content: msg.content.clone(),
|
||||||
@ -983,7 +1070,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{resolve_ws_sender_id, build_media_filename, process_attachments_with_base64};
|
use super::{build_media_filename, process_attachments_with_base64, resolve_ws_sender_id};
|
||||||
use crate::protocol::MediaSummary;
|
use crate::protocol::MediaSummary;
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
|
||||||
|
|||||||
@ -20,5 +20,5 @@ pub mod scheduler;
|
|||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod topic_description;
|
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
pub mod topic_description;
|
||||||
|
|||||||
@ -45,7 +45,9 @@ pub fn init_logging(timezone: Tz) {
|
|||||||
static INIT: Once = Once::new();
|
static INIT: Once = Once::new();
|
||||||
|
|
||||||
let mut initialized = false;
|
let mut initialized = false;
|
||||||
INIT.call_once(|| { initialized = true; });
|
INIT.call_once(|| {
|
||||||
|
initialized = true;
|
||||||
|
});
|
||||||
if !initialized {
|
if !initialized {
|
||||||
// Already initialized (e.g. after gateway restart), skip
|
// Already initialized (e.g. after gateway restart), skip
|
||||||
return;
|
return;
|
||||||
|
|||||||
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 {
|
if std::env::args().len() <= 1 {
|
||||||
cmd.print_help()?;
|
cmd.print_help()?;
|
||||||
println!();
|
println!();
|
||||||
return Ok(())
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
match Command::parse() {
|
match Command::parse() {
|
||||||
Command::Init { force, skip_channels } => {
|
Command::Init {
|
||||||
|
force,
|
||||||
|
skip_channels,
|
||||||
|
} => {
|
||||||
let mut wizard = picobot::cli::InitWizard::new();
|
let mut wizard = picobot::cli::InitWizard::new();
|
||||||
wizard.run(force, skip_channels).await?;
|
wizard.run(force, skip_channels).await?;
|
||||||
}
|
}
|
||||||
@ -56,14 +59,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
picobot::client::run(&url).await?;
|
picobot::client::run(&url).await?;
|
||||||
}
|
}
|
||||||
Command::Gateway { host, port } => {
|
Command::Gateway { host, port } => {
|
||||||
loop {
|
let mut should_restart = true;
|
||||||
let should_restart = picobot::gateway::run(host.clone(), port).await?;
|
while should_restart {
|
||||||
if !should_restart {
|
should_restart = picobot::gateway::run(host.clone(), port).await?;
|
||||||
break;
|
if should_restart {
|
||||||
}
|
|
||||||
tracing::info!("Gateway restarting...");
|
tracing::info!("Gateway restarting...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,14 +10,16 @@ use std::collections::HashMap;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use http::{HeaderName, HeaderValue};
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool},
|
|
||||||
RoleClient, ServiceExt,
|
RoleClient, ServiceExt,
|
||||||
|
model::{CallToolRequestParams, CallToolResult, ServerInfo, Tool},
|
||||||
service::RunningService,
|
service::RunningService,
|
||||||
transport::TokioChildProcess,
|
transport::TokioChildProcess,
|
||||||
transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig},
|
transport::streamable_http_client::{
|
||||||
|
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use http::{HeaderName, HeaderValue};
|
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
@ -64,7 +66,11 @@ fn resolve_command_path(command: &str) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If it has a path separator (relative path), don't search PATH
|
// If it has a path separator (relative path), don't search PATH
|
||||||
if path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false) {
|
if path
|
||||||
|
.parent()
|
||||||
|
.map(|p| !p.as_os_str().is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,7 +214,10 @@ impl McpClientManager {
|
|||||||
"Failed to connect to MCP server after all retries"
|
"Failed to connect to MCP server after all retries"
|
||||||
);
|
);
|
||||||
// Record error for status reporting
|
// Record error for status reporting
|
||||||
self.connection_errors.write().await.insert(key.clone(), e.to_string());
|
self.connection_errors
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(key.clone(), e.to_string());
|
||||||
failed += 1;
|
failed += 1;
|
||||||
} else {
|
} else {
|
||||||
// Clear any previous error on successful connection
|
// Clear any previous error on successful connection
|
||||||
@ -238,18 +247,23 @@ impl McpClientManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to a single MCP server
|
/// Connect to a single MCP server
|
||||||
pub async fn connect_server(&self, key: &str, config: &McpServerConfig) -> anyhow::Result<McpServerInfo> {
|
pub async fn connect_server(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
config: &McpServerConfig,
|
||||||
|
) -> anyhow::Result<McpServerInfo> {
|
||||||
let effective_name = config.effective_name(key);
|
let effective_name = config.effective_name(key);
|
||||||
tracing::info!(key = %key, name = %effective_name, transport_type = %config.transport_type, "Connecting to MCP server");
|
tracing::info!(key = %key, name = %effective_name, transport_type = %config.transport_type, "Connecting to MCP server");
|
||||||
|
|
||||||
let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?;
|
let transport = config.transport().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||||
let client = match transport {
|
let client = match transport {
|
||||||
McpTransportConfig::Stdio { command, args, env, cwd } => {
|
McpTransportConfig::Stdio {
|
||||||
self.connect_stdio(key, &command, &args, &env, &cwd).await?
|
command,
|
||||||
}
|
args,
|
||||||
McpTransportConfig::Http { url, headers } => {
|
env,
|
||||||
self.connect_http(&url, &headers).await?
|
cwd,
|
||||||
}
|
} => self.connect_stdio(key, &command, &args, &env, &cwd).await?,
|
||||||
|
McpTransportConfig::Http { url, headers } => self.connect_http(&url, &headers).await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get server info (returns Option<Arc<ServerInfo>> in rmcp 1.8+)
|
// Get server info (returns Option<Arc<ServerInfo>> in rmcp 1.8+)
|
||||||
@ -299,7 +313,10 @@ impl McpClientManager {
|
|||||||
if resolved_command.is_none() {
|
if resolved_command.is_none() {
|
||||||
let path = std::path::Path::new(command);
|
let path = std::path::Path::new(command);
|
||||||
let is_absolute = path.is_absolute();
|
let is_absolute = path.is_absolute();
|
||||||
let has_separator = path.parent().map(|p| !p.as_os_str().is_empty()).unwrap_or(false);
|
let has_separator = path
|
||||||
|
.parent()
|
||||||
|
.map(|p| !p.as_os_str().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
if !is_absolute && !has_separator && path.extension().is_none() {
|
if !is_absolute && !has_separator && path.extension().is_none() {
|
||||||
// Bare name not found on Windows
|
// Bare name not found on Windows
|
||||||
let path_env = std::env::var("PATH").unwrap_or_default();
|
let path_env = std::env::var("PATH").unwrap_or_default();
|
||||||
@ -308,7 +325,8 @@ impl McpClientManager {
|
|||||||
Current PATH: {}. \
|
Current PATH: {}. \
|
||||||
Suggestion: use the full absolute path to the executable, \
|
Suggestion: use the full absolute path to the executable, \
|
||||||
or ensure the tool is installed and its directory is in PATH.",
|
or ensure the tool is installed and its directory is in PATH.",
|
||||||
command, path_env
|
command,
|
||||||
|
path_env
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -416,7 +434,8 @@ impl McpClientManager {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Track that we have a stdio (child process) connection
|
// Track that we have a stdio (child process) connection
|
||||||
self.stdio_client_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
self.stdio_client_count
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
|
||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
@ -446,9 +465,7 @@ impl McpClientManager {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(key, value)| {
|
.filter_map(|(key, value)| {
|
||||||
// Try to parse header name and value
|
// Try to parse header name and value
|
||||||
HeaderName::try_from(key.clone())
|
HeaderName::try_from(key.clone()).ok().and_then(|name| {
|
||||||
.ok()
|
|
||||||
.and_then(|name| {
|
|
||||||
HeaderValue::try_from(value.clone())
|
HeaderValue::try_from(value.clone())
|
||||||
.ok()
|
.ok()
|
||||||
.map(|val| (name, val))
|
.map(|val| (name, val))
|
||||||
@ -457,14 +474,12 @@ impl McpClientManager {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Create transport config with custom headers
|
// Create transport config with custom headers
|
||||||
let config = StreamableHttpClientTransportConfig::with_uri(url)
|
let config =
|
||||||
.custom_headers(custom_headers);
|
StreamableHttpClientTransportConfig::with_uri(url).custom_headers(custom_headers);
|
||||||
|
|
||||||
// Create transport using reqwest client (default)
|
// Create transport using reqwest client (default)
|
||||||
let transport = StreamableHttpClientTransport::with_client(
|
let transport =
|
||||||
reqwest::Client::default(),
|
StreamableHttpClientTransport::with_client(reqwest::Client::default(), config);
|
||||||
config,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Connect
|
// Connect
|
||||||
let client = ().serve(transport).await?;
|
let client = ().serve(transport).await?;
|
||||||
@ -497,7 +512,9 @@ impl McpClientManager {
|
|||||||
info_map
|
info_map
|
||||||
.values()
|
.values()
|
||||||
.flat_map(|info| {
|
.flat_map(|info| {
|
||||||
info.tools.iter().map(|tool| (info.key.clone(), tool.clone()))
|
info.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| (info.key.clone(), tool.clone()))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@ -561,7 +578,9 @@ impl McpClientManager {
|
|||||||
/// gateway restart where old MCP processes may still be running when
|
/// gateway restart where old MCP processes may still be running when
|
||||||
/// new ones start.
|
/// new ones start.
|
||||||
pub async fn shutdown_all(&self) -> anyhow::Result<()> {
|
pub async fn shutdown_all(&self) -> anyhow::Result<()> {
|
||||||
let stdio_count = self.stdio_client_count.load(std::sync::atomic::Ordering::SeqCst);
|
let stdio_count = self
|
||||||
|
.stdio_client_count
|
||||||
|
.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
|
||||||
// Drop all clients (triggers cancellation + graceful shutdown in rmcp)
|
// Drop all clients (triggers cancellation + graceful shutdown in rmcp)
|
||||||
self.disconnect_all().await?;
|
self.disconnect_all().await?;
|
||||||
@ -578,7 +597,8 @@ impl McpClientManager {
|
|||||||
);
|
);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||||
tracing::info!("MCP child process cleanup wait complete");
|
tracing::info!("MCP child process cleanup wait complete");
|
||||||
self.stdio_client_count.store(0, std::sync::atomic::Ordering::SeqCst);
|
self.stdio_client_count
|
||||||
|
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -766,7 +786,10 @@ impl McpInitializer {
|
|||||||
///
|
///
|
||||||
/// This should be called after the gateway is ready to accept tools.
|
/// This should be called after the gateway is ready to accept tools.
|
||||||
/// Waits for connections to complete before registering tools.
|
/// Waits for connections to complete before registering tools.
|
||||||
pub async fn register_tools(&mut self, registry: &mut crate::tools::ToolRegistry) -> anyhow::Result<()> {
|
pub async fn register_tools(
|
||||||
|
&mut self,
|
||||||
|
registry: &mut crate::tools::ToolRegistry,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
if let Some(manager) = self.manager.clone() {
|
if let Some(manager) = self.manager.clone() {
|
||||||
// Wait for connections to complete first
|
// Wait for connections to complete first
|
||||||
self.wait_for_connections().await?;
|
self.wait_for_connections().await?;
|
||||||
@ -789,9 +812,15 @@ mod tests {
|
|||||||
// On all platforms, this should return None (either because it's not found,
|
// On all platforms, this should return None (either because it's not found,
|
||||||
// or because non-Windows always returns None)
|
// or because non-Windows always returns None)
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
assert!(result.is_none(), "Expected None for nonexistent command on Windows");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"Expected None for nonexistent command on Windows"
|
||||||
|
);
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
assert!(result.is_none(), "Expected None on non-Windows (always returns None)");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"Expected None on non-Windows (always returns None)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -806,7 +835,10 @@ mod tests {
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
assert!(result.is_some(), "Expected to find {} on Windows", cmd);
|
assert!(result.is_some(), "Expected to find {} on Windows", cmd);
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
assert!(result.is_none(), "Expected None on non-Windows for absolute path");
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"Expected None on non-Windows for absolute path"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -820,7 +852,10 @@ mod tests {
|
|||||||
fn test_resolve_command_path_finds_known_windows_binary() {
|
fn test_resolve_command_path_finds_known_windows_binary() {
|
||||||
// cmd.exe should always be in C:\Windows\System32 which is in PATH
|
// cmd.exe should always be in C:\Windows\System32 which is in PATH
|
||||||
let result = resolve_command_path("cmd");
|
let result = resolve_command_path("cmd");
|
||||||
assert!(result.is_some(), "Expected to find cmd.exe via PATH on Windows");
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"Expected to find cmd.exe via PATH on Windows"
|
||||||
|
);
|
||||||
if let Some(p) = result {
|
if let Some(p) = result {
|
||||||
assert!(
|
assert!(
|
||||||
p.to_string_lossy().to_lowercase().ends_with("cmd.exe"),
|
p.to_string_lossy().to_lowercase().ends_with("cmd.exe"),
|
||||||
|
|||||||
@ -203,7 +203,11 @@ mod tests {
|
|||||||
let config = McpServerConfig::stdio(
|
let config = McpServerConfig::stdio(
|
||||||
"filesystem",
|
"filesystem",
|
||||||
"npx",
|
"npx",
|
||||||
vec!["-y".to_string(), "@modelcontextprotocol/server-filesystem".to_string(), "/tmp".to_string()],
|
vec![
|
||||||
|
"-y".to_string(),
|
||||||
|
"@modelcontextprotocol/server-filesystem".to_string(),
|
||||||
|
"/tmp".to_string(),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(config.name, Some("filesystem".to_string()));
|
assert_eq!(config.name, Some("filesystem".to_string()));
|
||||||
@ -267,11 +271,14 @@ mod tests {
|
|||||||
match transport {
|
match transport {
|
||||||
McpTransportConfig::Stdio { command, args, .. } => {
|
McpTransportConfig::Stdio { command, args, .. } => {
|
||||||
assert_eq!(command, "npx");
|
assert_eq!(command, "npx");
|
||||||
assert_eq!(args, vec![
|
assert_eq!(
|
||||||
|
args,
|
||||||
|
vec![
|
||||||
"-y",
|
"-y",
|
||||||
"@modelcontextprotocol/server-filesystem",
|
"@modelcontextprotocol/server-filesystem",
|
||||||
"/home/user"
|
"/home/user"
|
||||||
]);
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_ => panic!("Expected stdio transport"),
|
_ => panic!("Expected stdio transport"),
|
||||||
}
|
}
|
||||||
@ -279,12 +286,18 @@ mod tests {
|
|||||||
// Check WebSearch server (streamableHttp)
|
// Check WebSearch server (streamableHttp)
|
||||||
let websearch = config.mcp_servers.get("WebSearch").unwrap();
|
let websearch = config.mcp_servers.get("WebSearch").unwrap();
|
||||||
assert_eq!(websearch.transport_type, "streamableHttp");
|
assert_eq!(websearch.transport_type, "streamableHttp");
|
||||||
assert_eq!(websearch.name, Some("AliyunBailianMCP_WebSearch".to_string()));
|
assert_eq!(
|
||||||
|
websearch.name,
|
||||||
|
Some("AliyunBailianMCP_WebSearch".to_string())
|
||||||
|
);
|
||||||
assert!(websearch.is_active);
|
assert!(websearch.is_active);
|
||||||
let transport = websearch.transport().unwrap();
|
let transport = websearch.transport().unwrap();
|
||||||
match transport {
|
match transport {
|
||||||
McpTransportConfig::Http { url, headers } => {
|
McpTransportConfig::Http { url, headers } => {
|
||||||
assert_eq!(url, "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp");
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
headers.get("Authorization"),
|
headers.get("Authorization"),
|
||||||
Some(&"Bearer ${DASHSCOPE_API_KEY}".to_string())
|
Some(&"Bearer ${DASHSCOPE_API_KEY}".to_string())
|
||||||
@ -385,17 +398,31 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_http_type_alias() {
|
fn test_http_type_alias() {
|
||||||
// Both "http" and "streamableHttp" should work
|
// Both "http" and "streamableHttp" should work
|
||||||
let json_http = r#"{"mcpServers": {"test": {"type": "http", "baseUrl": "http://localhost"}}}"#;
|
let json_http =
|
||||||
|
r#"{"mcpServers": {"test": {"type": "http", "baseUrl": "http://localhost"}}}"#;
|
||||||
let json_streamable = r#"{"mcpServers": {"test": {"type": "streamableHttp", "baseUrl": "http://localhost"}}}"#;
|
let json_streamable = r#"{"mcpServers": {"test": {"type": "streamableHttp", "baseUrl": "http://localhost"}}}"#;
|
||||||
|
|
||||||
let config_http: McpConfig = serde_json::from_str(json_http).unwrap();
|
let config_http: McpConfig = serde_json::from_str(json_http).unwrap();
|
||||||
let config_streamable: McpConfig = serde_json::from_str(json_streamable).unwrap();
|
let config_streamable: McpConfig = serde_json::from_str(json_streamable).unwrap();
|
||||||
|
|
||||||
let transport_http = config_http.mcp_servers.get("test").unwrap().transport().unwrap();
|
let transport_http = config_http
|
||||||
let transport_streamable = config_streamable.mcp_servers.get("test").unwrap().transport().unwrap();
|
.mcp_servers
|
||||||
|
.get("test")
|
||||||
|
.unwrap()
|
||||||
|
.transport()
|
||||||
|
.unwrap();
|
||||||
|
let transport_streamable = config_streamable
|
||||||
|
.mcp_servers
|
||||||
|
.get("test")
|
||||||
|
.unwrap()
|
||||||
|
.transport()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(transport_http, McpTransportConfig::Http { .. }));
|
assert!(matches!(transport_http, McpTransportConfig::Http { .. }));
|
||||||
assert!(matches!(transport_streamable, McpTransportConfig::Http { .. }));
|
assert!(matches!(
|
||||||
|
transport_streamable,
|
||||||
|
McpTransportConfig::Http { .. }
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -11,10 +11,12 @@
|
|||||||
//!
|
//!
|
||||||
//! MCP is completely optional and disabled by default.
|
//! MCP is completely optional and disabled by default.
|
||||||
|
|
||||||
pub mod config;
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod config;
|
||||||
pub mod tool_adapter;
|
pub mod tool_adapter;
|
||||||
|
|
||||||
|
pub use client::{
|
||||||
|
McpClient, McpClientManager, McpInitializer, McpServerInfo, McpServerStatus, McpStatusResponse,
|
||||||
|
};
|
||||||
pub use config::{McpConfig, McpServerConfig, McpTransportConfig};
|
pub use config::{McpConfig, McpServerConfig, McpTransportConfig};
|
||||||
pub use client::{McpClientManager, McpClient, McpServerInfo, McpInitializer, McpServerStatus, McpStatusResponse};
|
|
||||||
pub use tool_adapter::{McpToolWrapper, register_mcp_tools};
|
pub use tool_adapter::{McpToolWrapper, register_mcp_tools};
|
||||||
@ -25,11 +25,7 @@ pub struct McpToolWrapper {
|
|||||||
|
|
||||||
impl McpToolWrapper {
|
impl McpToolWrapper {
|
||||||
/// Create a new tool wrapper
|
/// Create a new tool wrapper
|
||||||
pub fn new(
|
pub fn new(manager: Arc<McpClientManager>, server_key: String, tool_info: Tool) -> Self {
|
||||||
manager: Arc<McpClientManager>,
|
|
||||||
server_key: String,
|
|
||||||
tool_info: Tool,
|
|
||||||
) -> Self {
|
|
||||||
let tool_name = tool_info.name.clone().into_owned();
|
let tool_name = tool_info.name.clone().into_owned();
|
||||||
let full_name = format!("mcp_{}_{}", server_key, tool_name);
|
let full_name = format!("mcp_{}_{}", server_key, tool_name);
|
||||||
Self {
|
Self {
|
||||||
@ -128,11 +124,7 @@ pub async fn register_mcp_tools(
|
|||||||
let all_tools = manager.all_tools().await;
|
let all_tools = manager.all_tools().await;
|
||||||
|
|
||||||
for (server_key, tool_info) in all_tools {
|
for (server_key, tool_info) in all_tools {
|
||||||
let wrapper = McpToolWrapper::new(
|
let wrapper = McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info);
|
||||||
manager.clone(),
|
|
||||||
server_key.clone(),
|
|
||||||
tool_info,
|
|
||||||
);
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
name = %wrapper.name(),
|
name = %wrapper.name(),
|
||||||
@ -153,10 +145,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_text_content_from_text() {
|
fn test_extract_text_content_from_text() {
|
||||||
let result = CallToolResult::success(vec![
|
let result = CallToolResult::success(vec![Content::text("Hello"), Content::text("World")]);
|
||||||
Content::text("Hello"),
|
|
||||||
Content::text("World"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let text = extract_text_content(&result);
|
let text = extract_text_content(&result);
|
||||||
assert_eq!(text, "Hello\nWorld");
|
assert_eq!(text, "Hello\nWorld");
|
||||||
@ -175,7 +164,8 @@ mod tests {
|
|||||||
fn test_mcp_tool_wrapper_name() {
|
fn test_mcp_tool_wrapper_name() {
|
||||||
let manager = Arc::new(McpClientManager::new());
|
let manager = Arc::new(McpClientManager::new());
|
||||||
// Create a minimal tool info using rmcp's Tool constructor
|
// Create a minimal tool info using rmcp's Tool constructor
|
||||||
let schema: serde_json::Map<String, serde_json::Value> = serde_json::json!({"type": "object"})
|
let schema: serde_json::Map<String, serde_json::Value> =
|
||||||
|
serde_json::json!({"type": "object"})
|
||||||
.as_object()
|
.as_object()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.clone();
|
.clone();
|
||||||
|
|||||||
@ -300,7 +300,8 @@ mod tests {
|
|||||||
fn test_truncate_args_utf8_boundary() {
|
fn test_truncate_args_utf8_boundary() {
|
||||||
// Test that truncation respects UTF-8 character boundaries
|
// Test that truncation respects UTF-8 character boundaries
|
||||||
// Each Chinese character is 3 bytes in UTF-8
|
// Each Chinese character is 3 bytes in UTF-8
|
||||||
let long_args = serde_json::json!({"key": "测试测试测试测试测试测试测试测试测试测试测试测试测试测试"});
|
let long_args =
|
||||||
|
serde_json::json!({"key": "测试测试测试测试测试测试测试测试测试测试测试测试测试测试"});
|
||||||
let truncated = truncate_args(&long_args, 50);
|
let truncated = truncate_args(&long_args, 50);
|
||||||
assert!(truncated.ends_with("...truncated"));
|
assert!(truncated.ends_with("...truncated"));
|
||||||
// Verify the truncated string is valid UTF-8 (no panic occurred)
|
// Verify the truncated string is valid UTF-8 (no panic occurred)
|
||||||
|
|||||||
@ -151,11 +151,7 @@ pub fn is_process_waiting_on_stdin(pid: u32) -> Option<bool> {
|
|||||||
if wchan.is_empty() {
|
if wchan.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(
|
Some(wchan.contains("tty_read") || wchan.contains("n_tty_read") || wchan == "pipe_wait")
|
||||||
wchan.contains("tty_read")
|
|
||||||
|| wchan.contains("n_tty_read")
|
|
||||||
|| wchan == "pipe_wait",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
|
|||||||
@ -240,9 +240,7 @@ pub enum WsOutbound {
|
|||||||
channel_name: Option<String>,
|
channel_name: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "channel_list")]
|
#[serde(rename = "channel_list")]
|
||||||
ChannelList {
|
ChannelList { channels: Vec<Channel> },
|
||||||
channels: Vec<Channel>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "topic_list")]
|
#[serde(rename = "topic_list")]
|
||||||
TopicList {
|
TopicList {
|
||||||
topics: Vec<TopicSummary>,
|
topics: Vec<TopicSummary>,
|
||||||
@ -262,7 +260,10 @@ pub enum WsOutbound {
|
|||||||
message_count: i64,
|
message_count: i64,
|
||||||
},
|
},
|
||||||
#[serde(rename = "session_saved")]
|
#[serde(rename = "session_saved")]
|
||||||
SessionSaved { session_id: String, filepath: String },
|
SessionSaved {
|
||||||
|
session_id: String,
|
||||||
|
filepath: String,
|
||||||
|
},
|
||||||
#[serde(rename = "task_messages_loaded")]
|
#[serde(rename = "task_messages_loaded")]
|
||||||
TaskMessagesLoaded {
|
TaskMessagesLoaded {
|
||||||
task_id: String,
|
task_id: String,
|
||||||
@ -273,17 +274,11 @@ pub enum WsOutbound {
|
|||||||
summary: Option<String>,
|
summary: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "scheduler_job_list")]
|
#[serde(rename = "scheduler_job_list")]
|
||||||
SchedulerJobList {
|
SchedulerJobList { jobs: Vec<SchedulerJobSummary> },
|
||||||
jobs: Vec<SchedulerJobSummary>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "memory_list")]
|
#[serde(rename = "memory_list")]
|
||||||
MemoryList {
|
MemoryList { memories: Vec<MemorySummary> },
|
||||||
memories: Vec<MemorySummary>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "skill_list")]
|
#[serde(rename = "skill_list")]
|
||||||
SkillList {
|
SkillList { skills: Vec<SkillSummary> },
|
||||||
skills: Vec<SkillSummary>,
|
|
||||||
},
|
|
||||||
#[serde(rename = "execution_cancelled")]
|
#[serde(rename = "execution_cancelled")]
|
||||||
ExecutionCancelled { message: String },
|
ExecutionCancelled { message: String },
|
||||||
#[serde(rename = "stream_delta")]
|
#[serde(rename = "stream_delta")]
|
||||||
|
|||||||
@ -15,7 +15,8 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
"assistant" => {
|
"assistant" => {
|
||||||
if let Some(tool_calls) = &message.tool_calls {
|
if let Some(tool_calls) = &message.tool_calls {
|
||||||
let mut outbound = Vec::new();
|
let mut outbound = Vec::new();
|
||||||
let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some();
|
let has_content_or_reasoning =
|
||||||
|
!message.content.trim().is_empty() || message.reasoning_content.is_some();
|
||||||
if has_content_or_reasoning {
|
if has_content_or_reasoning {
|
||||||
outbound.push(WsOutbound::AssistantResponse {
|
outbound.push(WsOutbound::AssistantResponse {
|
||||||
id: message.id.clone(),
|
id: message.id.clone(),
|
||||||
@ -31,7 +32,11 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复
|
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复
|
||||||
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() };
|
let tc_reasoning = if has_content_or_reasoning {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
message.reasoning_content.clone()
|
||||||
|
};
|
||||||
outbound.extend(tool_calls.iter().map(|tool_call| WsOutbound::ToolCall {
|
outbound.extend(tool_calls.iter().map(|tool_call| WsOutbound::ToolCall {
|
||||||
id: tool_call.id.clone(),
|
id: tool_call.id.clone(),
|
||||||
tool_call_id: tool_call.id.clone(),
|
tool_call_id: tool_call.id.clone(),
|
||||||
@ -66,7 +71,10 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
.unwrap_or(&ToolMessageState::Completed)
|
.unwrap_or(&ToolMessageState::Completed)
|
||||||
{
|
{
|
||||||
ToolMessageState::Completed => vec![WsOutbound::ToolResult {
|
ToolMessageState::Completed => vec![WsOutbound::ToolResult {
|
||||||
id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
id: message
|
||||||
|
.tool_call_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||||
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
||||||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
@ -77,7 +85,10 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
|
|||||||
timestamp: None,
|
timestamp: None,
|
||||||
}],
|
}],
|
||||||
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
|
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
|
||||||
id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
id: message
|
||||||
|
.tool_call_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||||
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
|
||||||
tool_name: message.tool_name.clone().unwrap_or_default(),
|
tool_name: message.tool_name.clone().unwrap_or_default(),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
@ -107,7 +118,10 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
vec![WsOutbound::AssistantResponse {
|
vec![WsOutbound::AssistantResponse {
|
||||||
id: message.message_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
id: message
|
||||||
|
.message_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||||
content: message.content.clone(),
|
content: message.content.clone(),
|
||||||
role: message.role.clone(),
|
role: message.role.clone(),
|
||||||
attachments,
|
attachments,
|
||||||
@ -176,8 +190,16 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
|||||||
}],
|
}],
|
||||||
OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted {
|
OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted {
|
||||||
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
|
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
|
||||||
description: message.metadata.get("task_description").cloned().unwrap_or_default(),
|
description: message
|
||||||
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
|
.metadata
|
||||||
|
.get("task_description")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
subagent_type: message
|
||||||
|
.metadata
|
||||||
|
.get("task_subagent_type")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
topic_id: message.metadata.get("topic_id").cloned(),
|
topic_id: message.metadata.get("topic_id").cloned(),
|
||||||
parent_task_id: message.metadata.get("parent_task_id").cloned(),
|
parent_task_id: message.metadata.get("parent_task_id").cloned(),
|
||||||
tool_call_id: message.metadata.get("tool_call_id").cloned(),
|
tool_call_id: message.metadata.get("tool_call_id").cloned(),
|
||||||
|
|||||||
@ -41,7 +41,9 @@ fn convert_content_blocks(
|
|||||||
) -> Vec<serde_json::Value> {
|
) -> Vec<serde_json::Value> {
|
||||||
// 检查是否有图片且模型不支持
|
// 检查是否有图片且模型不支持
|
||||||
if !supports_images {
|
if !supports_images {
|
||||||
let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
|
let has_images = blocks
|
||||||
|
.iter()
|
||||||
|
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
|
||||||
|
|
||||||
if has_images {
|
if has_images {
|
||||||
let image_count = blocks
|
let image_count = blocks
|
||||||
@ -79,10 +81,8 @@ fn convert_content_blocks(
|
|||||||
|
|
||||||
// 添加通知文本块
|
// 添加通知文本块
|
||||||
if !notices.is_empty() {
|
if !notices.is_empty() {
|
||||||
let notice_text = format!(
|
let notice_text =
|
||||||
"[系统提示] 以下图片未能成功入模:\n{}",
|
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
|
||||||
notices.join("\n")
|
|
||||||
);
|
|
||||||
converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text }));
|
converted_blocks.push(serde_json::json!({ "type": "text", "text": notice_text }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -182,9 +182,7 @@ impl AnthropicProvider {
|
|||||||
self.model_extra
|
self.model_extra
|
||||||
.get("supported_content_types")
|
.get("supported_content_types")
|
||||||
.and_then(|value| value.as_array())
|
.and_then(|value| value.as_array())
|
||||||
.map(|types| {
|
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
|
||||||
types.iter().any(|t| t.as_str() == Some(content_type))
|
|
||||||
})
|
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,11 @@ use super::traits::{StreamCallback, StreamDelta, Usage};
|
|||||||
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall};
|
use super::{ChatCompletionRequest, ChatCompletionResponse, LLMProvider, ToolCall};
|
||||||
use crate::domain::messages::ContentBlock;
|
use crate::domain::messages::ContentBlock;
|
||||||
|
|
||||||
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &["tool_call_arguments_json", "mock_response_content", "supported_content_types"];
|
const INTERNAL_MODEL_EXTRA_KEYS: &[&str] = &[
|
||||||
|
"tool_call_arguments_json",
|
||||||
|
"mock_response_content",
|
||||||
|
"supported_content_types",
|
||||||
|
];
|
||||||
|
|
||||||
/// 流式响应中的工具调用增量
|
/// 流式响应中的工具调用增量
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@ -49,8 +53,17 @@ impl StreamingAccumulator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 添加工具调用增量
|
/// 添加工具调用增量
|
||||||
fn add_tool_call(&mut self, index: usize, id: Option<&str>, name: Option<&str>, arguments: Option<&str>) {
|
fn add_tool_call(
|
||||||
let entry = self.tool_calls.entry(index).or_insert_with(StreamingToolCall::default);
|
&mut self,
|
||||||
|
index: usize,
|
||||||
|
id: Option<&str>,
|
||||||
|
name: Option<&str>,
|
||||||
|
arguments: Option<&str>,
|
||||||
|
) {
|
||||||
|
let entry = self
|
||||||
|
.tool_calls
|
||||||
|
.entry(index)
|
||||||
|
.or_insert_with(StreamingToolCall::default);
|
||||||
|
|
||||||
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
|
// 只在 id 非空时才更新,防止流式响应中后续 chunk 的空 id 覆盖之前的值
|
||||||
if let Some(id) = id {
|
if let Some(id) = id {
|
||||||
@ -78,7 +91,8 @@ impl StreamingAccumulator {
|
|||||||
|
|
||||||
/// 构建最终的 ChatCompletionResponse
|
/// 构建最终的 ChatCompletionResponse
|
||||||
fn build_response(self, model: String) -> ChatCompletionResponse {
|
fn build_response(self, model: String) -> ChatCompletionResponse {
|
||||||
let tool_calls: Vec<ToolCall> = self.tool_calls
|
let tool_calls: Vec<ToolCall> = self
|
||||||
|
.tool_calls
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
|
.filter(|(_, call)| !call.id.is_empty() && !call.name.is_empty())
|
||||||
.map(|(_, call)| {
|
.map(|(_, call)| {
|
||||||
@ -149,10 +163,13 @@ fn convert_content_blocks(
|
|||||||
) -> Value {
|
) -> Value {
|
||||||
// 检查是否有图片且模型不支持
|
// 检查是否有图片且模型不支持
|
||||||
if !supports_images {
|
if !supports_images {
|
||||||
let has_images = blocks.iter().any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
|
let has_images = blocks
|
||||||
|
.iter()
|
||||||
|
.any(|b| matches!(b, ContentBlock::ImageUrl { .. }));
|
||||||
|
|
||||||
if has_images {
|
if has_images {
|
||||||
let image_count = blocks.iter()
|
let image_count = blocks
|
||||||
|
.iter()
|
||||||
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
|
.filter(|b| matches!(b, ContentBlock::ImageUrl { .. }))
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
@ -186,10 +203,8 @@ fn convert_content_blocks(
|
|||||||
|
|
||||||
// 添加通知文本块
|
// 添加通知文本块
|
||||||
if !notices.is_empty() {
|
if !notices.is_empty() {
|
||||||
let notice_text = format!(
|
let notice_text =
|
||||||
"[系统提示] 以下图片未能成功入模:\n{}",
|
format!("[系统提示] 以下图片未能成功入模:\n{}", notices.join("\n"));
|
||||||
notices.join("\n")
|
|
||||||
);
|
|
||||||
converted_blocks.push(json!({ "type": "text", "text": notice_text }));
|
converted_blocks.push(json!({ "type": "text", "text": notice_text }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,9 +318,7 @@ impl OpenAIProvider {
|
|||||||
self.model_extra
|
self.model_extra
|
||||||
.get("supported_content_types")
|
.get("supported_content_types")
|
||||||
.and_then(|value| value.as_array())
|
.and_then(|value| value.as_array())
|
||||||
.map(|types| {
|
.map(|types| types.iter().any(|t| t.as_str() == Some(content_type)))
|
||||||
types.iter().any(|t| t.as_str() == Some(content_type))
|
|
||||||
})
|
|
||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,7 +352,9 @@ impl OpenAIProvider {
|
|||||||
Value::String(raw)
|
Value::String(raw)
|
||||||
} else {
|
} else {
|
||||||
// Invalid JSON string - wrap it as a proper JSON string
|
// Invalid JSON string - wrap it as a proper JSON string
|
||||||
Value::String(serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string()))
|
Value::String(
|
||||||
|
serde_json::to_string(&raw).unwrap_or_else(|_| "null".to_string()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
value => Value::String(
|
value => Value::String(
|
||||||
@ -435,7 +450,8 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SSE 格式: data: {...} 或 data:{...}(某些 API 如 139 云没有空格)
|
// SSE 格式: data: {...} 或 data:{...}(某些 API 如 139 云没有空格)
|
||||||
let data_opt = line_trimmed.strip_prefix("data: ")
|
let data_opt = line_trimmed
|
||||||
|
.strip_prefix("data: ")
|
||||||
.or_else(|| line_trimmed.strip_prefix("data:"));
|
.or_else(|| line_trimmed.strip_prefix("data:"));
|
||||||
|
|
||||||
if let Some(data) = data_opt {
|
if let Some(data) = data_opt {
|
||||||
@ -459,7 +475,9 @@ impl OpenAIProvider {
|
|||||||
// 尝试从 delta 提取(标准 OpenAI 流式格式)
|
// 尝试从 delta 提取(标准 OpenAI 流式格式)
|
||||||
if let Some(delta) = choice.get("delta") {
|
if let Some(delta) = choice.get("delta") {
|
||||||
// 提取内容增量
|
// 提取内容增量
|
||||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
if let Some(content) =
|
||||||
|
delta.get("content").and_then(|c| c.as_str())
|
||||||
|
{
|
||||||
accumulator.add_content(content);
|
accumulator.add_content(content);
|
||||||
if let Some(cb) = &stream_callback {
|
if let Some(cb) = &stream_callback {
|
||||||
cb(StreamDelta {
|
cb(StreamDelta {
|
||||||
@ -470,7 +488,9 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 提取推理内容增量
|
// 提取推理内容增量
|
||||||
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
|
if let Some(reasoning) =
|
||||||
|
delta.get("reasoning_content").and_then(|r| r.as_str())
|
||||||
|
{
|
||||||
accumulator.add_reasoning_content(reasoning);
|
accumulator.add_reasoning_content(reasoning);
|
||||||
if let Some(cb) = &stream_callback {
|
if let Some(cb) = &stream_callback {
|
||||||
cb(StreamDelta {
|
cb(StreamDelta {
|
||||||
@ -481,28 +501,43 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 提取工具调用增量
|
// 提取工具调用增量
|
||||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
|
if let Some(tool_calls) =
|
||||||
|
delta.get("tool_calls").and_then(|t| t.as_array())
|
||||||
|
{
|
||||||
for tool_call in tool_calls {
|
for tool_call in tool_calls {
|
||||||
let index = tool_call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
|
let index = tool_call
|
||||||
|
.get("index")
|
||||||
|
.and_then(|i| i.as_u64())
|
||||||
|
.unwrap_or(0)
|
||||||
|
as usize;
|
||||||
|
|
||||||
let id = tool_call.get("id").and_then(|v| v.as_str());
|
let id =
|
||||||
let name = tool_call.get("function")
|
tool_call.get("id").and_then(|v| v.as_str());
|
||||||
|
let name = tool_call
|
||||||
|
.get("function")
|
||||||
.and_then(|f| f.get("name"))
|
.and_then(|f| f.get("name"))
|
||||||
.and_then(|n| n.as_str());
|
.and_then(|n| n.as_str());
|
||||||
let arguments = tool_call.get("function")
|
let arguments = tool_call
|
||||||
|
.get("function")
|
||||||
.and_then(|f| f.get("arguments"))
|
.and_then(|f| f.get("arguments"))
|
||||||
.and_then(|a| a.as_str());
|
.and_then(|a| a.as_str());
|
||||||
|
|
||||||
accumulator.add_tool_call(index, id, name, arguments);
|
accumulator
|
||||||
|
.add_tool_call(index, id, name, arguments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 尝试从 message 提取(某些非标准 API 格式)
|
// 尝试从 message 提取(某些非标准 API 格式)
|
||||||
else if let Some(message) = choice.get("message") {
|
else if let Some(message) = choice.get("message") {
|
||||||
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
|
if let Some(content) =
|
||||||
|
message.get("content").and_then(|c| c.as_str())
|
||||||
|
{
|
||||||
accumulator.add_content(content);
|
accumulator.add_content(content);
|
||||||
}
|
}
|
||||||
if let Some(reasoning) = message.get("reasoning_content").and_then(|r| r.as_str()) {
|
if let Some(reasoning) = message
|
||||||
|
.get("reasoning_content")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
{
|
||||||
accumulator.add_reasoning_content(reasoning);
|
accumulator.add_reasoning_content(reasoning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -533,7 +568,8 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 同样支持 data: {...} 和 data:{...} 两种格式
|
// 同样支持 data: {...} 和 data:{...} 两种格式
|
||||||
let data_opt = line_trimmed.strip_prefix("data: ")
|
let data_opt = line_trimmed
|
||||||
|
.strip_prefix("data: ")
|
||||||
.or_else(|| line_trimmed.strip_prefix("data:"));
|
.or_else(|| line_trimmed.strip_prefix("data:"));
|
||||||
|
|
||||||
if let Some(data) = data_opt {
|
if let Some(data) = data_opt {
|
||||||
@ -550,7 +586,8 @@ impl OpenAIProvider {
|
|||||||
for choice in choices {
|
for choice in choices {
|
||||||
// 尝试从 delta 提取
|
// 尝试从 delta 提取
|
||||||
if let Some(delta) = choice.get("delta") {
|
if let Some(delta) = choice.get("delta") {
|
||||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
if let Some(content) = delta.get("content").and_then(|c| c.as_str())
|
||||||
|
{
|
||||||
accumulator.add_content(content);
|
accumulator.add_content(content);
|
||||||
if let Some(cb) = &stream_callback {
|
if let Some(cb) = &stream_callback {
|
||||||
cb(StreamDelta {
|
cb(StreamDelta {
|
||||||
@ -559,7 +596,9 @@ impl OpenAIProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
|
if let Some(reasoning) =
|
||||||
|
delta.get("reasoning_content").and_then(|r| r.as_str())
|
||||||
|
{
|
||||||
accumulator.add_reasoning_content(reasoning);
|
accumulator.add_reasoning_content(reasoning);
|
||||||
if let Some(cb) = &stream_callback {
|
if let Some(cb) = &stream_callback {
|
||||||
cb(StreamDelta {
|
cb(StreamDelta {
|
||||||
@ -568,14 +607,22 @@ impl OpenAIProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|t| t.as_array()) {
|
if let Some(tool_calls) =
|
||||||
|
delta.get("tool_calls").and_then(|t| t.as_array())
|
||||||
|
{
|
||||||
for tool_call in tool_calls {
|
for tool_call in tool_calls {
|
||||||
let index = tool_call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
|
let index = tool_call
|
||||||
|
.get("index")
|
||||||
|
.and_then(|i| i.as_u64())
|
||||||
|
.unwrap_or(0)
|
||||||
|
as usize;
|
||||||
let id = tool_call.get("id").and_then(|v| v.as_str());
|
let id = tool_call.get("id").and_then(|v| v.as_str());
|
||||||
let name = tool_call.get("function")
|
let name = tool_call
|
||||||
|
.get("function")
|
||||||
.and_then(|f| f.get("name"))
|
.and_then(|f| f.get("name"))
|
||||||
.and_then(|n| n.as_str());
|
.and_then(|n| n.as_str());
|
||||||
let arguments = tool_call.get("function")
|
let arguments = tool_call
|
||||||
|
.get("function")
|
||||||
.and_then(|f| f.get("arguments"))
|
.and_then(|f| f.get("arguments"))
|
||||||
.and_then(|a| a.as_str());
|
.and_then(|a| a.as_str());
|
||||||
accumulator.add_tool_call(index, id, name, arguments);
|
accumulator.add_tool_call(index, id, name, arguments);
|
||||||
@ -584,10 +631,14 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
// 尝试从 message 提取(某些非标准 API 格式)
|
// 尝试从 message 提取(某些非标准 API 格式)
|
||||||
else if let Some(message) = choice.get("message") {
|
else if let Some(message) = choice.get("message") {
|
||||||
if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
|
if let Some(content) =
|
||||||
|
message.get("content").and_then(|c| c.as_str())
|
||||||
|
{
|
||||||
accumulator.add_content(content);
|
accumulator.add_content(content);
|
||||||
}
|
}
|
||||||
if let Some(reasoning) = message.get("reasoning_content").and_then(|r| r.as_str()) {
|
if let Some(reasoning) =
|
||||||
|
message.get("reasoning_content").and_then(|r| r.as_str())
|
||||||
|
{
|
||||||
accumulator.add_reasoning_content(reasoning);
|
accumulator.add_reasoning_content(reasoning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -603,7 +654,8 @@ impl OpenAIProvider {
|
|||||||
// 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体
|
// 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体
|
||||||
if response.content.is_empty() && response.tool_calls.is_empty() {
|
if response.content.is_empty() && response.tool_calls.is_empty() {
|
||||||
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
|
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
|
||||||
let fallback_content = openai_resp.choices
|
let fallback_content = openai_resp
|
||||||
|
.choices
|
||||||
.first()
|
.first()
|
||||||
.and_then(|c| c.message.content.as_deref())
|
.and_then(|c| c.message.content.as_deref())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
@ -614,22 +666,29 @@ impl OpenAIProvider {
|
|||||||
"Streaming accumulator empty, falling back to non-SSE JSON parsing"
|
"Streaming accumulator empty, falling back to non-SSE JSON parsing"
|
||||||
);
|
);
|
||||||
response.content = fallback_content;
|
response.content = fallback_content;
|
||||||
response.reasoning_content = openai_resp.choices
|
response.reasoning_content = openai_resp
|
||||||
|
.choices
|
||||||
.first()
|
.first()
|
||||||
.and_then(|c| c.message.reasoning_content.clone());
|
.and_then(|c| c.message.reasoning_content.clone());
|
||||||
response.tool_calls = openai_resp.choices
|
response.tool_calls = openai_resp
|
||||||
|
.choices
|
||||||
.first()
|
.first()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
c.message.tool_calls.iter().map(|tc| ToolCall {
|
c.message
|
||||||
|
.tool_calls
|
||||||
|
.iter()
|
||||||
|
.map(|tc| ToolCall {
|
||||||
id: tc.id.clone(),
|
id: tc.id.clone(),
|
||||||
name: tc.function.name.clone(),
|
name: tc.function.name.clone(),
|
||||||
arguments: match &tc.function.arguments {
|
arguments: match &tc.function.arguments {
|
||||||
OAIFunctionArguments::Json(args) => args.clone(),
|
OAIFunctionArguments::Json(args) => args.clone(),
|
||||||
OAIFunctionArguments::String(args) => {
|
OAIFunctionArguments::String(args) => {
|
||||||
serde_json::from_str(args).unwrap_or(serde_json::Value::Null)
|
serde_json::from_str(args)
|
||||||
|
.unwrap_or(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}).collect()
|
})
|
||||||
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
}
|
}
|
||||||
@ -656,9 +715,11 @@ impl OpenAIProvider {
|
|||||||
// result that precedes its parent assistant (e.g. after compaction
|
// result that precedes its parent assistant (e.g. after compaction
|
||||||
// boundary splits), leading to API 400 errors:
|
// boundary splits), leading to API 400 errors:
|
||||||
// "insufficient tool messages following tool_calls message".
|
// "insufficient tool messages following tool_calls message".
|
||||||
let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
let mut resolved_tool_ids: std::collections::HashSet<&str> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
let mut skip_assistant_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
let mut skip_assistant_indices: std::collections::HashSet<usize> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
|
|
||||||
for (i, m) in request.messages.iter().enumerate().rev() {
|
for (i, m) in request.messages.iter().enumerate().rev() {
|
||||||
if m.role == "tool" {
|
if m.role == "tool" {
|
||||||
@ -670,8 +731,9 @@ impl OpenAIProvider {
|
|||||||
if m.role == "assistant" {
|
if m.role == "assistant" {
|
||||||
if let Some(ref calls) = m.tool_calls {
|
if let Some(ref calls) = m.tool_calls {
|
||||||
if !calls.is_empty() {
|
if !calls.is_empty() {
|
||||||
let all_resolved =
|
let all_resolved = calls
|
||||||
calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
.iter()
|
||||||
|
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
||||||
if all_resolved {
|
if all_resolved {
|
||||||
for tc in calls {
|
for tc in calls {
|
||||||
with_parent.insert(tc.id.as_str());
|
with_parent.insert(tc.id.as_str());
|
||||||
@ -695,7 +757,8 @@ impl OpenAIProvider {
|
|||||||
// ^ reverse scan sees tool(A) after assistant → "resolved"
|
// ^ reverse scan sees tool(A) after assistant → "resolved"
|
||||||
// but API requires tool(A) to be IMMEDIATELY after assistant
|
// but API requires tool(A) to be IMMEDIATELY after assistant
|
||||||
{
|
{
|
||||||
let mut pending_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
let mut pending_tool_ids: std::collections::HashSet<&str> =
|
||||||
|
std::collections::HashSet::new();
|
||||||
let mut pending_assistant_idx: Option<usize> = None;
|
let mut pending_assistant_idx: Option<usize> = None;
|
||||||
|
|
||||||
for (i, m) in request.messages.iter().enumerate() {
|
for (i, m) in request.messages.iter().enumerate() {
|
||||||
@ -892,12 +955,17 @@ impl OpenAIProvider {
|
|||||||
/// avoid flooding logs on every request — see callers in `chat` and
|
/// avoid flooding logs on every request — see callers in `chat` and
|
||||||
/// `chat_streaming_internal`.
|
/// `chat_streaming_internal`.
|
||||||
fn format_message_sequence(body: &Value) -> Vec<String> {
|
fn format_message_sequence(body: &Value) -> Vec<String> {
|
||||||
body["messages"].as_array()
|
body["messages"]
|
||||||
.map(|msgs| msgs.iter().enumerate().map(|(i, m)| {
|
.as_array()
|
||||||
|
.map(|msgs| {
|
||||||
|
msgs.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| {
|
||||||
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
|
let role = m.get("role").and_then(|r| r.as_str()).unwrap_or("?");
|
||||||
match role {
|
match role {
|
||||||
"assistant" => {
|
"assistant" => {
|
||||||
let tc_count = m.get("tool_calls")
|
let tc_count = m
|
||||||
|
.get("tool_calls")
|
||||||
.and_then(|t| t.as_array())
|
.and_then(|t| t.as_array())
|
||||||
.map(|a| a.len())
|
.map(|a| a.len())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
@ -908,14 +976,17 @@ fn format_message_sequence(body: &Value) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"tool" => {
|
"tool" => {
|
||||||
let tcid = m.get("tool_call_id")
|
let tcid = m
|
||||||
|
.get("tool_call_id")
|
||||||
.and_then(|t| t.as_str())
|
.and_then(|t| t.as_str())
|
||||||
.unwrap_or("??");
|
.unwrap_or("??");
|
||||||
format!("[{}] tool(id={})", i, tcid)
|
format!("[{}] tool(id={})", i, tcid)
|
||||||
}
|
}
|
||||||
_ => format!("[{}] {}", i, role),
|
_ => format!("[{}] {}", i, role),
|
||||||
}
|
}
|
||||||
}).collect())
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1141,7 +1212,10 @@ impl LLMProvider for OpenAIProvider {
|
|||||||
callback: StreamCallback,
|
callback: StreamCallback,
|
||||||
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<ChatCompletionResponse, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
if self.is_streaming_enabled() {
|
if self.is_streaming_enabled() {
|
||||||
match self.chat_streaming_internal(&request, Some(&callback)).await {
|
match self
|
||||||
|
.chat_streaming_internal(&request, Some(&callback))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(response) => return Ok(response),
|
Ok(response) => return Ok(response),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@ -1471,7 +1545,12 @@ mod tests {
|
|||||||
let mut accumulator = StreamingAccumulator::new();
|
let mut accumulator = StreamingAccumulator::new();
|
||||||
|
|
||||||
// 第一个 chunk:包含完整的 id 和 name
|
// 第一个 chunk:包含完整的 id 和 name
|
||||||
accumulator.add_tool_call(0, Some("call_abc123"), Some("memory_search"), Some("{\"action\":\""));
|
accumulator.add_tool_call(
|
||||||
|
0,
|
||||||
|
Some("call_abc123"),
|
||||||
|
Some("memory_search"),
|
||||||
|
Some("{\"action\":\""),
|
||||||
|
);
|
||||||
// 第二个 chunk:只有参数增量
|
// 第二个 chunk:只有参数增量
|
||||||
accumulator.add_tool_call(0, None, None, Some("list"));
|
accumulator.add_tool_call(0, None, None, Some("list"));
|
||||||
// 第三个 chunk:参数继续
|
// 第三个 chunk:参数继续
|
||||||
@ -1487,7 +1566,10 @@ mod tests {
|
|||||||
assert_eq!(response.tool_calls.len(), 1);
|
assert_eq!(response.tool_calls.len(), 1);
|
||||||
assert_eq!(response.tool_calls[0].id, "call_abc123");
|
assert_eq!(response.tool_calls[0].id, "call_abc123");
|
||||||
assert_eq!(response.tool_calls[0].name, "memory_search");
|
assert_eq!(response.tool_calls[0].name, "memory_search");
|
||||||
assert_eq!(response.tool_calls[0].arguments, json!({"action":"list", "limit": 20}));
|
assert_eq!(
|
||||||
|
response.tool_calls[0].arguments,
|
||||||
|
json!({"action":"list", "limit": 20})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1495,7 +1577,12 @@ mod tests {
|
|||||||
let mut accumulator = StreamingAccumulator::new();
|
let mut accumulator = StreamingAccumulator::new();
|
||||||
|
|
||||||
// 第一个工具调用
|
// 第一个工具调用
|
||||||
accumulator.add_tool_call(0, Some("call_1"), Some("calculator"), Some("{\"expr\": \"1+1\"}"));
|
accumulator.add_tool_call(
|
||||||
|
0,
|
||||||
|
Some("call_1"),
|
||||||
|
Some("calculator"),
|
||||||
|
Some("{\"expr\": \"1+1\"}"),
|
||||||
|
);
|
||||||
// 第二个工具调用(id 和 name 只在第一个 chunk 出现)
|
// 第二个工具调用(id 和 name 只在第一个 chunk 出现)
|
||||||
accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}"));
|
accumulator.add_tool_call(1, Some("call_2"), Some("get_time"), Some("{}"));
|
||||||
|
|
||||||
@ -1600,7 +1687,10 @@ mod tests {
|
|||||||
"supported_content_types".to_string(),
|
"supported_content_types".to_string(),
|
||||||
Value::Array(vec![Value::String("text".to_string())]),
|
Value::Array(vec![Value::String("text".to_string())]),
|
||||||
),
|
),
|
||||||
("custom_param".to_string(), Value::String("value".to_string())),
|
(
|
||||||
|
"custom_param".to_string(),
|
||||||
|
Value::String("value".to_string()),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1737,7 +1827,8 @@ mod tests {
|
|||||||
let messages = body["messages"].as_array().unwrap();
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
|
||||||
// Assistant should keep tool_calls (valid immediate sequence)
|
// Assistant should keep tool_calls (valid immediate sequence)
|
||||||
let tool_calls = messages[0].get("tool_calls")
|
let tool_calls = messages[0]
|
||||||
|
.get("tool_calls")
|
||||||
.and_then(|t| t.as_array())
|
.and_then(|t| t.as_array())
|
||||||
.expect("tool_calls should be preserved when immediately followed");
|
.expect("tool_calls should be preserved when immediately followed");
|
||||||
assert_eq!(tool_calls.len(), 1);
|
assert_eq!(tool_calls.len(), 1);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
use crate::config::LLMProviderConfig;
|
||||||
use crate::domain::messages::{ContentBlock, ToolCall};
|
use crate::domain::messages::{ContentBlock, ToolCall};
|
||||||
use crate::domain::tools::Tool;
|
use crate::domain::tools::Tool;
|
||||||
use crate::config::LLMProviderConfig;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@ -59,7 +59,9 @@ pub trait AgentTaskExecutor: Send + Sync {
|
|||||||
pub trait MaintenanceExecutor: Send + Sync {
|
pub trait MaintenanceExecutor: Send + Sync {
|
||||||
async fn cleanup_expired_sessions(&self) -> usize;
|
async fn cleanup_expired_sessions(&self) -> usize;
|
||||||
|
|
||||||
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>>;
|
async fn run_memory_maintenance_for_all_scopes(
|
||||||
|
&self,
|
||||||
|
) -> anyhow::Result<Vec<MaintenanceRunSummary>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Scheduler {
|
pub struct Scheduler {
|
||||||
@ -452,11 +454,15 @@ fn scheduler_job_definition_matches(
|
|||||||
existing: &SchedulerJobRecord,
|
existing: &SchedulerJobRecord,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let input_schedule = serde_json::from_value::<SchedulerSchedule>(input.schedule.clone()).ok();
|
let input_schedule = serde_json::from_value::<SchedulerSchedule>(input.schedule.clone()).ok();
|
||||||
let existing_schedule =
|
let existing_schedule = deserialize_schedule(
|
||||||
deserialize_schedule(&existing.schedule, existing.interval_secs, existing.startup_delay_secs)
|
&existing.schedule,
|
||||||
|
existing.interval_secs,
|
||||||
|
existing.startup_delay_secs,
|
||||||
|
)
|
||||||
.ok();
|
.ok();
|
||||||
let input_target = serde_json::from_value::<SchedulerJobTarget>(input.target.clone()).ok();
|
let input_target = serde_json::from_value::<SchedulerJobTarget>(input.target.clone()).ok();
|
||||||
let existing_target = serde_json::from_value::<SchedulerJobTarget>(existing.target.clone()).ok();
|
let existing_target =
|
||||||
|
serde_json::from_value::<SchedulerJobTarget>(existing.target.clone()).ok();
|
||||||
let targets_match = match (input_target, existing_target) {
|
let targets_match = match (input_target, existing_target) {
|
||||||
(Some(input_target), Some(existing_target)) => {
|
(Some(input_target), Some(existing_target)) => {
|
||||||
input_target.channel == existing_target.channel
|
input_target.channel == existing_target.channel
|
||||||
@ -813,7 +819,10 @@ fn convert_weekday_field(expression: &str) -> String {
|
|||||||
let weekday_field = parts[5];
|
let weekday_field = parts[5];
|
||||||
let converted = convert_cron_weekday(weekday_field);
|
let converted = convert_cron_weekday(weekday_field);
|
||||||
|
|
||||||
format!("{} {} {} {} {} {}", parts[0], parts[1], parts[2], parts[3], parts[4], converted)
|
format!(
|
||||||
|
"{} {} {} {} {} {}",
|
||||||
|
parts[0], parts[1], parts[2], parts[3], parts[4], converted
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转换星期表达式中的数字
|
/// 转换星期表达式中的数字
|
||||||
@ -824,9 +833,10 @@ fn convert_cron_weekday(field: &str) -> String {
|
|||||||
|
|
||||||
// 处理列表(逗号分隔)
|
// 处理列表(逗号分隔)
|
||||||
let items: Vec<&str> = field.split(',').collect();
|
let items: Vec<&str> = field.split(',').collect();
|
||||||
let converted_items: Vec<String> = items.iter().map(|item| {
|
let converted_items: Vec<String> = items
|
||||||
convert_weekday_item(item.trim())
|
.iter()
|
||||||
}).collect();
|
.map(|item| convert_weekday_item(item.trim()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
converted_items.join(",")
|
converted_items.join(",")
|
||||||
}
|
}
|
||||||
@ -929,7 +939,9 @@ async fn execute_internal_event(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
"memory_maintenance" => {
|
"memory_maintenance" => {
|
||||||
let results = maintenance_executor.run_memory_maintenance_for_all_scopes().await?;
|
let results = maintenance_executor
|
||||||
|
.run_memory_maintenance_for_all_scopes()
|
||||||
|
.await?;
|
||||||
for result in &results {
|
for result in &results {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
job_id = %job.id,
|
job_id = %job.id,
|
||||||
@ -1284,10 +1296,10 @@ impl TryFrom<serde_json::Value> for SchedulerJobTarget {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use chrono::{Datelike, Timelike};
|
|
||||||
use crate::bus::MessageBus;
|
use crate::bus::MessageBus;
|
||||||
use crate::config::BUILTIN_MEMORY_MAINTENANCE_JOB_ID;
|
use crate::config::BUILTIN_MEMORY_MAINTENANCE_JOB_ID;
|
||||||
use crate::storage::{SchedulerJobUpsert, SessionStore};
|
use crate::storage::{SchedulerJobUpsert, SessionStore};
|
||||||
|
use chrono::{Datelike, Timelike};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TestAgentTaskExecutor;
|
struct TestAgentTaskExecutor;
|
||||||
@ -1325,7 +1337,9 @@ mod tests {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_memory_maintenance_for_all_scopes(&self) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
|
async fn run_memory_maintenance_for_all_scopes(
|
||||||
|
&self,
|
||||||
|
) -> anyhow::Result<Vec<MaintenanceRunSummary>> {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1592,17 +1606,19 @@ mod tests {
|
|||||||
|
|
||||||
let probe_runtime = RuntimeJob::from_config(
|
let probe_runtime = RuntimeJob::from_config(
|
||||||
&config_job,
|
&config_job,
|
||||||
Utc.timestamp_millis_opt(1_700_000_000_000).single().unwrap(),
|
Utc.timestamp_millis_opt(1_700_000_000_000)
|
||||||
|
.single()
|
||||||
|
.unwrap(),
|
||||||
SchedulerMisfirePolicy::Skip,
|
SchedulerMisfirePolicy::Skip,
|
||||||
chrono_tz::Asia::Shanghai,
|
chrono_tz::Asia::Shanghai,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let probe_existing = store
|
let probe_existing = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
|
||||||
.get_scheduler_job("agent.heartbeat")
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let probe_upsert = probe_runtime.to_upsert();
|
let probe_upsert = probe_runtime.to_upsert();
|
||||||
assert!(scheduler_job_definition_matches(&probe_upsert, &probe_existing));
|
assert!(scheduler_job_definition_matches(
|
||||||
|
&probe_upsert,
|
||||||
|
&probe_existing
|
||||||
|
));
|
||||||
|
|
||||||
let (agent_task_executor, maintenance_service) = test_scheduler_services();
|
let (agent_task_executor, maintenance_service) = test_scheduler_services();
|
||||||
let scheduler = Scheduler::new(
|
let scheduler = Scheduler::new(
|
||||||
@ -1622,10 +1638,7 @@ mod tests {
|
|||||||
|
|
||||||
scheduler.sync_config_jobs().unwrap();
|
scheduler.sync_config_jobs().unwrap();
|
||||||
|
|
||||||
let saved = store
|
let saved = store.get_scheduler_job("agent.heartbeat").unwrap().unwrap();
|
||||||
.get_scheduler_job("agent.heartbeat")
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(saved.next_fire_at, Some(persisted_next_fire_at));
|
assert_eq!(saved.next_fire_at, Some(persisted_next_fire_at));
|
||||||
assert_eq!(saved.run_count, 3);
|
assert_eq!(saved.run_count, 3);
|
||||||
@ -1723,7 +1736,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_cron_weekday_definitions() {
|
fn debug_cron_weekday_definitions() {
|
||||||
// 重大发现:cron crate 的星期定义是反常规的!
|
// 重大发现:cron crate 的星期定义是反常规的!
|
||||||
@ -1742,9 +1754,16 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 从周六(2026-04-25)开始测试
|
// 从周六(2026-04-25)开始测试
|
||||||
let saturday = Utc.with_ymd_and_hms(2026, 4, 25, 10, 0, 0).single().unwrap();
|
let saturday = Utc
|
||||||
|
.with_ymd_and_hms(2026, 4, 25, 10, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap();
|
||||||
let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai);
|
let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai);
|
||||||
println!("\n=== 从周六 {} ({:?}) 开始测试 ===", shanghai_saturday, shanghai_saturday.weekday());
|
println!(
|
||||||
|
"\n=== 从周六 {} ({:?}) 开始测试 ===",
|
||||||
|
shanghai_saturday,
|
||||||
|
shanghai_saturday.weekday()
|
||||||
|
);
|
||||||
|
|
||||||
for (expr, desc) in &test_cases {
|
for (expr, desc) in &test_cases {
|
||||||
let schedule = parse_scheduler_cron(expr).unwrap();
|
let schedule = parse_scheduler_cron(expr).unwrap();
|
||||||
@ -1757,21 +1776,49 @@ mod tests {
|
|||||||
let schedule_workday = parse_scheduler_cron("0 9 * * 1-5").unwrap();
|
let schedule_workday = parse_scheduler_cron("0 9 * * 1-5").unwrap();
|
||||||
|
|
||||||
let sat_next = schedule_workday.after(&shanghai_saturday).next().unwrap();
|
let sat_next = schedule_workday.after(&shanghai_saturday).next().unwrap();
|
||||||
println!("周六 -> 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday());
|
println!(
|
||||||
assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周六跳到周一");
|
"周六 -> 1-5 下次执行: {} (星期: {:?})",
|
||||||
|
sat_next,
|
||||||
|
sat_next.weekday()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sat_next.weekday(),
|
||||||
|
chrono::Weekday::Mon,
|
||||||
|
"1-5 应该从周六跳到周一"
|
||||||
|
);
|
||||||
|
|
||||||
// 从周日开始
|
// 从周日开始
|
||||||
let sunday = Utc.with_ymd_and_hms(2026, 4, 26, 10, 0, 0).single().unwrap();
|
let sunday = Utc
|
||||||
|
.with_ymd_and_hms(2026, 4, 26, 10, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap();
|
||||||
let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai);
|
let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai);
|
||||||
let sun_next = schedule_workday.after(&shanghai_sunday).next().unwrap();
|
let sun_next = schedule_workday.after(&shanghai_sunday).next().unwrap();
|
||||||
println!("周日 -> 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday());
|
println!(
|
||||||
assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "1-5 应该从周日跳到周一");
|
"周日 -> 1-5 下次执行: {} (星期: {:?})",
|
||||||
|
sun_next,
|
||||||
|
sun_next.weekday()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sun_next.weekday(),
|
||||||
|
chrono::Weekday::Mon,
|
||||||
|
"1-5 应该从周日跳到周一"
|
||||||
|
);
|
||||||
|
|
||||||
// 从周一早上7点开始
|
// 从周一早上7点开始
|
||||||
let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap();
|
let shanghai_monday = chrono_tz::Asia::Shanghai
|
||||||
println!("周一早上7点 -> 1-5 下次执行: {} (星期: {:?})",
|
.with_ymd_and_hms(2026, 4, 27, 7, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap();
|
||||||
|
println!(
|
||||||
|
"周一早上7点 -> 1-5 下次执行: {} (星期: {:?})",
|
||||||
schedule_workday.after(&shanghai_monday).next().unwrap(),
|
schedule_workday.after(&shanghai_monday).next().unwrap(),
|
||||||
schedule_workday.after(&shanghai_monday).next().unwrap().weekday());
|
schedule_workday
|
||||||
|
.after(&shanghai_monday)
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.weekday()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试标准 cron 星期转换功能
|
/// 测试标准 cron 星期转换功能
|
||||||
@ -1784,36 +1831,77 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn standard_cron_weekday_conversion() {
|
fn standard_cron_weekday_conversion() {
|
||||||
// 测试:标准 cron 的 1-5 应该表示周一到周五
|
// 测试:标准 cron 的 1-5 应该表示周一到周五
|
||||||
let saturday = Utc.with_ymd_and_hms(2026, 4, 25, 10, 0, 0).single().unwrap();
|
let saturday = Utc
|
||||||
|
.with_ymd_and_hms(2026, 4, 25, 10, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap();
|
||||||
let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai);
|
let shanghai_saturday = saturday.with_timezone(&chrono_tz::Asia::Shanghai);
|
||||||
|
|
||||||
// 现在使用标准 cron:1-5 表示周一到周五
|
// 现在使用标准 cron:1-5 表示周一到周五
|
||||||
let schedule_std = parse_scheduler_cron("0 9 * * 1-5").unwrap();
|
let schedule_std = parse_scheduler_cron("0 9 * * 1-5").unwrap();
|
||||||
|
|
||||||
let sat_next = schedule_std.after(&shanghai_saturday).next().unwrap();
|
let sat_next = schedule_std.after(&shanghai_saturday).next().unwrap();
|
||||||
println!("周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sat_next, sat_next.weekday());
|
println!(
|
||||||
assert_eq!(sat_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周六跳到周一");
|
"周六 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
|
||||||
|
sat_next,
|
||||||
|
sat_next.weekday()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sat_next.weekday(),
|
||||||
|
chrono::Weekday::Mon,
|
||||||
|
"标准 cron 1-5 应该从周六跳到周一"
|
||||||
|
);
|
||||||
|
|
||||||
// 从周日开始
|
// 从周日开始
|
||||||
let sunday = Utc.with_ymd_and_hms(2026, 4, 26, 10, 0, 0).single().unwrap();
|
let sunday = Utc
|
||||||
|
.with_ymd_and_hms(2026, 4, 26, 10, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap();
|
||||||
let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai);
|
let shanghai_sunday = sunday.with_timezone(&chrono_tz::Asia::Shanghai);
|
||||||
let sun_next = schedule_std.after(&shanghai_sunday).next().unwrap();
|
let sun_next = schedule_std.after(&shanghai_sunday).next().unwrap();
|
||||||
println!("周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", sun_next, sun_next.weekday());
|
println!(
|
||||||
assert_eq!(sun_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周日跳到周一");
|
"周日 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
|
||||||
|
sun_next,
|
||||||
|
sun_next.weekday()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sun_next.weekday(),
|
||||||
|
chrono::Weekday::Mon,
|
||||||
|
"标准 cron 1-5 应该从周日跳到周一"
|
||||||
|
);
|
||||||
|
|
||||||
// 从周一开始(上海时间周一早上7点)
|
// 从周一开始(上海时间周一早上7点)
|
||||||
let shanghai_monday = chrono_tz::Asia::Shanghai.with_ymd_and_hms(2026, 4, 27, 7, 0, 0).single().unwrap();
|
let shanghai_monday = chrono_tz::Asia::Shanghai
|
||||||
|
.with_ymd_and_hms(2026, 4, 27, 7, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap();
|
||||||
let mon_next = schedule_std.after(&shanghai_monday).next().unwrap();
|
let mon_next = schedule_std.after(&shanghai_monday).next().unwrap();
|
||||||
println!("周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", mon_next, mon_next.weekday());
|
println!(
|
||||||
assert_eq!(mon_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该当天执行");
|
"周一早上 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
|
||||||
|
mon_next,
|
||||||
|
mon_next.weekday()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mon_next.weekday(),
|
||||||
|
chrono::Weekday::Mon,
|
||||||
|
"标准 cron 1-5 应该当天执行"
|
||||||
|
);
|
||||||
assert_eq!(mon_next.hour(), 9, "应该是上海时间9点");
|
assert_eq!(mon_next.hour(), 9, "应该是上海时间9点");
|
||||||
|
|
||||||
// 从周五开始(应该下周周一)
|
// 从周五开始(应该下周周一)
|
||||||
let friday = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).single().unwrap(); // 周五
|
let friday = Utc.with_ymd_and_hms(2026, 5, 1, 10, 0, 0).single().unwrap(); // 周五
|
||||||
let shanghai_friday = friday.with_timezone(&chrono_tz::Asia::Shanghai);
|
let shanghai_friday = friday.with_timezone(&chrono_tz::Asia::Shanghai);
|
||||||
let fri_next = schedule_std.after(&shanghai_friday).next().unwrap();
|
let fri_next = schedule_std.after(&shanghai_friday).next().unwrap();
|
||||||
println!("周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})", fri_next, fri_next.weekday());
|
println!(
|
||||||
assert_eq!(fri_next.weekday(), chrono::Weekday::Mon, "标准 cron 1-5 应该从周五跳到下周一");
|
"周五 -> 标准 cron 1-5 下次执行: {} (星期: {:?})",
|
||||||
|
fri_next,
|
||||||
|
fri_next.weekday()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fri_next.weekday(),
|
||||||
|
chrono::Weekday::Mon,
|
||||||
|
"标准 cron 1-5 应该从周五跳到下周一"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试转换辅助函数
|
/// 测试转换辅助函数
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
use crate::platform::{atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape};
|
use crate::platform::{
|
||||||
|
atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@ -11,7 +13,9 @@ static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn acquire_skill_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
pub(crate) fn acquire_skill_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||||
SKILL_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner())
|
SKILL_TEST_ENV_LOCK
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|err| err.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::config::SkillsConfig;
|
use crate::config::SkillsConfig;
|
||||||
@ -209,16 +213,23 @@ impl SkillRuntime {
|
|||||||
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
|
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
|
||||||
let disable_state = load_skill_disable_state(&cwd);
|
let disable_state = load_skill_disable_state(&cwd);
|
||||||
|
|
||||||
catalog.skills.iter().map(|skill| {
|
catalog
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.map(|skill| {
|
||||||
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
|
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
|
||||||
SkillWithStatus {
|
SkillWithStatus {
|
||||||
name: skill.name.clone(),
|
name: skill.name.clone(),
|
||||||
description: skill.description.clone(),
|
description: skill.description.clone(),
|
||||||
source: skill.source.as_str().to_string(),
|
source: skill.source.as_str().to_string(),
|
||||||
path: skill.path.display().to_string(),
|
path: skill.path.display().to_string(),
|
||||||
disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
disabled_in_scopes: disabled_scopes
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str().to_string())
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}).collect()
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
||||||
@ -321,8 +332,8 @@ impl SkillRuntime {
|
|||||||
|
|
||||||
pub fn has_skill_definition(&self, name: &str) -> Result<bool, String> {
|
pub fn has_skill_definition(&self, name: &str) -> Result<bool, String> {
|
||||||
validate_skill_name(name)?;
|
validate_skill_name(name)?;
|
||||||
let cwd = std::env::current_dir()
|
let cwd =
|
||||||
.map_err(|err| format!("failed to get current dir: {}", err))?;
|
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
|
||||||
Ok(SkillCatalog::discover_without_state(&self.config, &cwd)
|
Ok(SkillCatalog::discover_without_state(&self.config, &cwd)
|
||||||
.find_skill(name)
|
.find_skill(name)
|
||||||
.is_some())
|
.is_some())
|
||||||
@ -358,8 +369,8 @@ impl SkillRuntime {
|
|||||||
let _ = self.reload()?;
|
let _ = self.reload()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cwd = std::env::current_dir()
|
let cwd =
|
||||||
.map_err(|err| format!("failed to get current dir: {}", err))?;
|
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
|
||||||
let effective_state = load_skill_disable_state(&cwd);
|
let effective_state = load_skill_disable_state(&cwd);
|
||||||
let disabled_in_scopes = effective_state.disabled_scopes_for(name);
|
let disabled_in_scopes = effective_state.disabled_scopes_for(name);
|
||||||
|
|
||||||
@ -756,8 +767,9 @@ fn skill_file_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
|
|||||||
|
|
||||||
fn skill_state_path(scope: SkillScope) -> Result<PathBuf, String> {
|
fn skill_state_path(scope: SkillScope) -> Result<PathBuf, String> {
|
||||||
match scope {
|
match scope {
|
||||||
SkillScope::User => user_skill_state_path()
|
SkillScope::User => {
|
||||||
.ok_or_else(|| "failed to resolve home directory".to_string()),
|
user_skill_state_path().ok_or_else(|| "failed to resolve home directory".to_string())
|
||||||
|
}
|
||||||
SkillScope::Project => {
|
SkillScope::Project => {
|
||||||
let cwd = std::env::current_dir()
|
let cwd = std::env::current_dir()
|
||||||
.map_err(|err| format!("failed to get current dir: {}", err))?;
|
.map_err(|err| format!("failed to get current dir: {}", err))?;
|
||||||
@ -966,10 +978,7 @@ impl SystemPromptProvider for SkillPromptProvider {
|
|||||||
// 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认)
|
// 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认)
|
||||||
let content = match context.session_id.as_deref() {
|
let content = match context.session_id.as_deref() {
|
||||||
Some(sid) => {
|
Some(sid) => {
|
||||||
let policy = self
|
let policy = self.experts.selected_expert_for(sid).map(|e| e.capability);
|
||||||
.experts
|
|
||||||
.selected_expert_for(sid)
|
|
||||||
.map(|e| e.capability);
|
|
||||||
match policy {
|
match policy {
|
||||||
Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered(
|
Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered(
|
||||||
p.allowed_skills.as_deref(),
|
p.allowed_skills.as_deref(),
|
||||||
@ -1058,7 +1067,11 @@ mod tests {
|
|||||||
let skill_dir = dir.path().join("demo");
|
let skill_dir = dir.path().join("demo");
|
||||||
fs::create_dir_all(&skill_dir).unwrap();
|
fs::create_dir_all(&skill_dir).unwrap();
|
||||||
let skill_md = skill_dir.join("SKILL.md");
|
let skill_md = skill_dir.join("SKILL.md");
|
||||||
fs::write(&skill_md, "---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B").unwrap();
|
fs::write(
|
||||||
|
&skill_md,
|
||||||
|
"---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
|
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
|
||||||
assert_eq!(skill.name, "demo");
|
assert_eq!(skill.name, "demo");
|
||||||
@ -1128,7 +1141,10 @@ mod tests {
|
|||||||
|
|
||||||
// 验证 location 包含正确的 file:// URI 格式
|
// 验证 location 包含正确的 file:// URI 格式
|
||||||
let expected_uri = path_to_uri(&skill_path);
|
let expected_uri = path_to_uri(&skill_path);
|
||||||
assert!(prompt.contains(&format!("<location>{}</location>", platform_xml_escape(&expected_uri))));
|
assert!(prompt.contains(&format!(
|
||||||
|
"<location>{}</location>",
|
||||||
|
platform_xml_escape(&expected_uri)
|
||||||
|
)));
|
||||||
assert!(prompt.contains("</available_skills>"));
|
assert!(prompt.contains("</available_skills>"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1388,13 +1404,17 @@ mod tests {
|
|||||||
max_listed_skills: 32,
|
max_listed_skills: 32,
|
||||||
});
|
});
|
||||||
|
|
||||||
let disabled = runtime.disable_skill(SkillScope::Project, "demo", true).unwrap();
|
let disabled = runtime
|
||||||
|
.disable_skill(SkillScope::Project, "demo", true)
|
||||||
|
.unwrap();
|
||||||
assert!(disabled.changed);
|
assert!(disabled.changed);
|
||||||
assert_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]);
|
assert_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]);
|
||||||
assert!(!disabled.available);
|
assert!(!disabled.available);
|
||||||
assert!(runtime.get_skill("demo").is_none());
|
assert!(runtime.get_skill("demo").is_none());
|
||||||
|
|
||||||
let enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
|
let enabled = runtime
|
||||||
|
.enable_skill(SkillScope::Project, "demo", true)
|
||||||
|
.unwrap();
|
||||||
assert!(enabled.changed);
|
assert!(enabled.changed);
|
||||||
assert!(enabled.disabled_in_scopes.is_empty());
|
assert!(enabled.disabled_in_scopes.is_empty());
|
||||||
assert!(enabled.available);
|
assert!(enabled.available);
|
||||||
@ -1427,16 +1447,22 @@ mod tests {
|
|||||||
max_listed_skills: 32,
|
max_listed_skills: 32,
|
||||||
});
|
});
|
||||||
|
|
||||||
let user_disabled = runtime.disable_skill(SkillScope::User, "demo", true).unwrap();
|
let user_disabled = runtime
|
||||||
|
.disable_skill(SkillScope::User, "demo", true)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(user_disabled.disabled_in_scopes, vec![SkillScope::User]);
|
assert_eq!(user_disabled.disabled_in_scopes, vec![SkillScope::User]);
|
||||||
assert!(runtime.get_skill("demo").is_none());
|
assert!(runtime.get_skill("demo").is_none());
|
||||||
|
|
||||||
let project_enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
|
let project_enabled = runtime
|
||||||
|
.enable_skill(SkillScope::Project, "demo", true)
|
||||||
|
.unwrap();
|
||||||
assert!(!project_enabled.available);
|
assert!(!project_enabled.available);
|
||||||
assert_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]);
|
assert_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]);
|
||||||
assert!(runtime.get_skill("demo").is_none());
|
assert!(runtime.get_skill("demo").is_none());
|
||||||
|
|
||||||
let user_enabled = runtime.enable_skill(SkillScope::User, "demo", true).unwrap();
|
let user_enabled = runtime
|
||||||
|
.enable_skill(SkillScope::User, "demo", true)
|
||||||
|
.unwrap();
|
||||||
assert!(user_enabled.available);
|
assert!(user_enabled.available);
|
||||||
assert!(user_enabled.disabled_in_scopes.is_empty());
|
assert!(user_enabled.disabled_in_scopes.is_empty());
|
||||||
assert!(runtime.get_skill("demo").is_some());
|
assert!(runtime.get_skill("demo").is_some());
|
||||||
@ -1506,7 +1532,9 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert_eq!(catalog.len(), 1);
|
assert_eq!(catalog.len(), 1);
|
||||||
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
|
let payload = catalog
|
||||||
|
.activation_event_payload("demo-user-openclaw")
|
||||||
|
.unwrap();
|
||||||
assert_eq!(payload["source"], "user_openclaw");
|
assert_eq!(payload["source"], "user_openclaw");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1565,7 +1593,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// After enabling, list_skills_with_status should report no disabled scopes
|
// After enabling, list_skills_with_status should report no disabled scopes
|
||||||
runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
|
runtime
|
||||||
|
.enable_skill(SkillScope::Project, "demo", true)
|
||||||
|
.unwrap();
|
||||||
let skills_after = runtime.list_skills_with_status();
|
let skills_after = runtime.list_skills_with_status();
|
||||||
assert_eq!(skills_after.len(), 1);
|
assert_eq!(skills_after.len(), 1);
|
||||||
assert!(skills_after[0].disabled_in_scopes.is_empty());
|
assert!(skills_after[0].disabled_in_scopes.is_empty());
|
||||||
|
|||||||
@ -24,10 +24,10 @@ pub use ports::{
|
|||||||
SkillEventRepository, TodoRepository,
|
SkillEventRepository, TodoRepository,
|
||||||
};
|
};
|
||||||
pub use records::{
|
pub use records::{
|
||||||
allowed_namespace_names, get_namespace_description, is_valid_namespace,
|
|
||||||
ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord,
|
ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord,
|
||||||
SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord,
|
SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord,
|
||||||
TodoRecord, TopicRecord,
|
TodoRecord, TopicRecord, allowed_namespace_names, get_namespace_description,
|
||||||
|
is_valid_namespace,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@ -228,14 +228,11 @@ impl SessionStore {
|
|||||||
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let manager = SqliteConnectionManager::file(db_uri)
|
let manager = SqliteConnectionManager::file(db_uri).with_init(|c| {
|
||||||
.with_init(|c| {
|
|
||||||
c.busy_timeout(std::time::Duration::from_secs(30))?;
|
c.busy_timeout(std::time::Duration::from_secs(30))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
let pool = Pool::builder()
|
let pool = Pool::builder().max_size(8).build(manager)?;
|
||||||
.max_size(8)
|
|
||||||
.build(manager)?;
|
|
||||||
|
|
||||||
Ok(Self { pool })
|
Ok(Self { pool })
|
||||||
}
|
}
|
||||||
@ -245,8 +242,7 @@ impl SessionStore {
|
|||||||
// Use a temp file so the database survives across pool connections.
|
// Use a temp file so the database survives across pool connections.
|
||||||
// Temp dir is cleaned by the OS eventually; tests that need cleanup
|
// Temp dir is cleaned by the OS eventually; tests that need cleanup
|
||||||
// can call std::fs::remove_file on the path.
|
// can call std::fs::remove_file on the path.
|
||||||
let path = std::env::temp_dir()
|
let path = std::env::temp_dir().join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
|
||||||
.join(format!("picobot_test_{}.db", uuid::Uuid::new_v4()));
|
|
||||||
let conn = Connection::open(&path)?;
|
let conn = Connection::open(&path)?;
|
||||||
let path_str = path.to_string_lossy().to_string();
|
let path_str = path.to_string_lossy().to_string();
|
||||||
// ignore unused mut warning for manager in tests
|
// ignore unused mut warning for manager in tests
|
||||||
@ -304,7 +300,12 @@ impl SessionStore {
|
|||||||
chat_id: &str,
|
chat_id: &str,
|
||||||
) -> Result<SessionRecord, StorageError> {
|
) -> Result<SessionRecord, StorageError> {
|
||||||
let session_id = persistent_session_id(channel_name, chat_id);
|
let session_id = persistent_session_id(channel_name, chat_id);
|
||||||
self.ensure_session(&session_id, channel_name, chat_id, &format!("{}:{}", channel_name, chat_id))
|
self.ensure_session(
|
||||||
|
&session_id,
|
||||||
|
channel_name,
|
||||||
|
chat_id,
|
||||||
|
&format!("{}:{}", channel_name, chat_id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 确保指定 session_id 的会话存在(如果不存在则创建)
|
/// 确保指定 session_id 的会话存在(如果不存在则创建)
|
||||||
@ -512,7 +513,11 @@ impl SessionStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_topic_description(&self, topic_id: &str, description: &str) -> Result<(), StorageError> {
|
pub fn update_topic_description(
|
||||||
|
&self,
|
||||||
|
topic_id: &str,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<(), StorageError> {
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
let conn = self.pool.get()?;
|
let conn = self.pool.get()?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@ -810,12 +815,7 @@ impl SessionStore {
|
|||||||
archived_at = NULL
|
archived_at = NULL
|
||||||
WHERE id = ?1 AND deleted_at IS NULL
|
WHERE id = ?1 AND deleted_at IS NULL
|
||||||
",
|
",
|
||||||
params![
|
params![session_id, inserted_count, active_user_turn_count, now,],
|
||||||
session_id,
|
|
||||||
inserted_count,
|
|
||||||
active_user_turn_count,
|
|
||||||
now,
|
|
||||||
],
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
@ -1583,7 +1583,8 @@ impl SessionStore {
|
|||||||
|
|
||||||
/// 获取指定话题的消息数量(动态计算,确保准确)
|
/// 获取指定话题的消息数量(动态计算,确保准确)
|
||||||
pub fn get_topic_message_count(&self, topic_id: &str) -> Result<usize, StorageError> {
|
pub fn get_topic_message_count(&self, topic_id: &str) -> Result<usize, StorageError> {
|
||||||
self.load_messages_for_topic(topic_id, None).map(|msgs| msgs.len())
|
self.load_messages_for_topic(topic_id, None)
|
||||||
|
.map(|msgs| msgs.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_all_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> {
|
pub fn load_all_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, StorageError> {
|
||||||
@ -1619,10 +1620,7 @@ impl SessionStore {
|
|||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
|
|
||||||
// Delete existing todos for this scope_key
|
// Delete existing todos for this scope_key
|
||||||
tx.execute(
|
tx.execute("DELETE FROM todos WHERE scope_key = ?1", params![scope_key])?;
|
||||||
"DELETE FROM todos WHERE scope_key = ?1",
|
|
||||||
params![scope_key],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// Insert new todos
|
// Insert new todos
|
||||||
for item in items {
|
for item in items {
|
||||||
|
|||||||
@ -8,18 +8,38 @@ pub const GLOBAL_SCOPE_KEY: &str = "default";
|
|||||||
/// 每个命名空间代表一类记忆内容,用于分类管理和检索。
|
/// 每个命名空间代表一类记忆内容,用于分类管理和检索。
|
||||||
/// 禁止使用未在此列表中的 namespace 创建记忆。
|
/// 禁止使用未在此列表中的 namespace 创建记忆。
|
||||||
pub const ALLOWED_MEMORY_NAMESPACES: &[(&str, &str)] = &[
|
pub const ALLOWED_MEMORY_NAMESPACES: &[(&str, &str)] = &[
|
||||||
("user", "用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作"),
|
(
|
||||||
("semantic", "语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累"),
|
"user",
|
||||||
("episodic", "情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪"),
|
"用户记忆:存储用户长期偏好、身份背景和历史协作信息,实现跨会话的个性化服务与持续协作",
|
||||||
("skill", "技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行"),
|
),
|
||||||
("environment", "环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力"),
|
(
|
||||||
("reflection", "反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进"),
|
"semantic",
|
||||||
|
"语义记忆:存储结构化或非结构化知识内容,支持知识检索、问答增强和长期知识积累",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"episodic",
|
||||||
|
"情景记忆:记录历史对话、任务执行过程及关键事件,支持经验回溯、案例复用和行为追踪",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"skill",
|
||||||
|
"技能记忆:存储技能定义、工作流、工具调用策略及最佳实践,支持能力复用与自动化执行",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"environment",
|
||||||
|
"环境记忆:存储外部系统状态、运行环境配置和实时资源信息,为智能决策提供环境感知能力",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"reflection",
|
||||||
|
"反思记忆:沉淀任务执行过程中的成功经验、失败原因和优化建议,支持智能体持续学习与自我改进",
|
||||||
|
),
|
||||||
("other", "其他记忆:不属于以上分类的其他记忆内容"),
|
("other", "其他记忆:不属于以上分类的其他记忆内容"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 验证 namespace 是否在允许列表中
|
/// 验证 namespace 是否在允许列表中
|
||||||
pub fn is_valid_namespace(namespace: &str) -> bool {
|
pub fn is_valid_namespace(namespace: &str) -> bool {
|
||||||
ALLOWED_MEMORY_NAMESPACES.iter().any(|(name, _)| *name == namespace)
|
ALLOWED_MEMORY_NAMESPACES
|
||||||
|
.iter()
|
||||||
|
.any(|(name, _)| *name == namespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 namespace 的中文描述
|
/// 获取 namespace 的中文描述
|
||||||
@ -32,7 +52,10 @@ pub fn get_namespace_description(namespace: &str) -> Option<&'static str> {
|
|||||||
|
|
||||||
/// 获取所有允许的 namespace 名称列表(用于 JSON schema enum)
|
/// 获取所有允许的 namespace 名称列表(用于 JSON schema enum)
|
||||||
pub fn allowed_namespace_names() -> Vec<&'static str> {
|
pub fn allowed_namespace_names() -> Vec<&'static str> {
|
||||||
ALLOWED_MEMORY_NAMESPACES.iter().map(|(name, _)| *name).collect()
|
ALLOWED_MEMORY_NAMESPACES
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _)| *name)
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@ -97,7 +97,9 @@ pub(super) fn map_session_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<Se
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn map_skill_event_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillEventRecord> {
|
pub(super) fn map_skill_event_record(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
) -> rusqlite::Result<SkillEventRecord> {
|
||||||
let payload_json: String = row.get(4)?;
|
let payload_json: String = row.get(4)?;
|
||||||
let payload = serde_json::from_str(&payload_json).map_err(|err| {
|
let payload = serde_json::from_str(&payload_json).map_err(|err| {
|
||||||
rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err))
|
rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err))
|
||||||
@ -129,11 +131,7 @@ pub(super) fn map_chat_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<
|
|||||||
.map(serde_json::from_str)
|
.map(serde_json::from_str)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
rusqlite::Error::FromSqlConversionFailure(
|
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(err))
|
||||||
9,
|
|
||||||
rusqlite::types::Type::Text,
|
|
||||||
Box::new(err),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(ChatMessage {
|
Ok(ChatMessage {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use super::*;
|
|
||||||
use super::migrations::has_column;
|
use super::migrations::has_column;
|
||||||
|
use super::*;
|
||||||
use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT;
|
use crate::bus::SYSTEM_CONTEXT_AGENT_PROMPT;
|
||||||
use crate::domain::messages::ToolCall;
|
use crate::domain::messages::ToolCall;
|
||||||
|
|
||||||
@ -10,10 +10,19 @@ fn test_persistent_session_id_for_cli_and_channel() {
|
|||||||
assert_eq!(persistent_session_id("cli", "abc"), "abc");
|
assert_eq!(persistent_session_id("cli", "abc"), "abc");
|
||||||
// 幂等:已带前缀的 chat_id 会被清理,不会累积前缀
|
// 幂等:已带前缀的 chat_id 会被清理,不会累积前缀
|
||||||
assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc");
|
assert_eq!(persistent_session_id("websocket", "websocket:abc"), "abc");
|
||||||
assert_eq!(persistent_session_id("websocket", "websocket:websocket:abc"), "abc");
|
assert_eq!(
|
||||||
assert_eq!(persistent_session_id(TEST_CHANNEL, "abc"), "test-channel:abc");
|
persistent_session_id("websocket", "websocket:websocket:abc"),
|
||||||
|
"abc"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
persistent_session_id(TEST_CHANNEL, "abc"),
|
||||||
|
"test-channel:abc"
|
||||||
|
);
|
||||||
// 其他通道也幂等
|
// 其他通道也幂等
|
||||||
assert_eq!(persistent_session_id(TEST_CHANNEL, "test-channel:abc"), "test-channel:abc");
|
assert_eq!(
|
||||||
|
persistent_session_id(TEST_CHANNEL, "test-channel:abc"),
|
||||||
|
"test-channel:abc"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -76,8 +85,12 @@ fn test_session_store_roundtrip_and_lifecycle() {
|
|||||||
fn test_ensure_channel_session_is_stable() {
|
fn test_ensure_channel_session_is_stable() {
|
||||||
let store = SessionStore::in_memory().unwrap();
|
let store = SessionStore::in_memory().unwrap();
|
||||||
|
|
||||||
let first = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap();
|
let first = store
|
||||||
let second = store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap();
|
.ensure_channel_session(TEST_CHANNEL, "chat-1")
|
||||||
|
.unwrap();
|
||||||
|
let second = store
|
||||||
|
.ensure_channel_session(TEST_CHANNEL, "chat-1")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(first.id, second.id);
|
assert_eq!(first.id, second.id);
|
||||||
assert_eq!(first.chat_id, "chat-1");
|
assert_eq!(first.chat_id, "chat-1");
|
||||||
@ -176,8 +189,7 @@ fn test_schema_migration_adds_user_turn_and_reinjection_columns() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_schema_migration_adds_reasoning_content_column_to_messages() {
|
fn test_schema_migration_adds_reasoning_content_column_to_messages() {
|
||||||
let tmp = std::env::temp_dir()
|
let tmp = std::env::temp_dir().join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4()));
|
||||||
.join(format!("picobot_test_mig_{}.db", uuid::Uuid::new_v4()));
|
|
||||||
let conn = Connection::open(&tmp).unwrap();
|
let conn = Connection::open(&tmp).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"
|
"
|
||||||
@ -225,10 +237,8 @@ fn test_compact_active_history_rebuilds_active_segment_with_delta_messages() {
|
|||||||
let store = SessionStore::in_memory().unwrap();
|
let store = SessionStore::in_memory().unwrap();
|
||||||
let session = store.create_cli_session(Some("compact-history")).unwrap();
|
let session = store.create_cli_session(Some("compact-history")).unwrap();
|
||||||
|
|
||||||
let agent_prompt = ChatMessage::system_with_context(
|
let agent_prompt =
|
||||||
"agent",
|
ChatMessage::system_with_context("agent", Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()));
|
||||||
Some(SYSTEM_CONTEXT_AGENT_PROMPT.to_string()),
|
|
||||||
);
|
|
||||||
let seed_messages = vec![
|
let seed_messages = vec![
|
||||||
agent_prompt.clone(),
|
agent_prompt.clone(),
|
||||||
ChatMessage::user("u1"),
|
ChatMessage::user("u1"),
|
||||||
@ -378,7 +388,10 @@ fn test_memory_roundtrip_with_source_fields() {
|
|||||||
|
|
||||||
assert_eq!(saved.content, "Rust");
|
assert_eq!(saved.content, "Rust");
|
||||||
assert_eq!(saved.source_type, "message");
|
assert_eq!(saved.source_type, "message");
|
||||||
assert_eq!(saved.source_session_id.as_deref(), Some("test-channel:chat-1"));
|
assert_eq!(
|
||||||
|
saved.source_session_id.as_deref(),
|
||||||
|
Some("test-channel:chat-1")
|
||||||
|
);
|
||||||
assert_eq!(saved.source_message_id.as_deref(), Some("msg-1"));
|
assert_eq!(saved.source_message_id.as_deref(), Some("msg-1"));
|
||||||
assert_eq!(saved.source_message_seq, Some(7));
|
assert_eq!(saved.source_message_seq, Some(7));
|
||||||
|
|
||||||
@ -474,7 +487,13 @@ fn test_memory_search_matches_memory_key_field() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let hits = store
|
let hits = store
|
||||||
.search_memories("user", "test-channel:user-1", "email_folder_preference", None, 10)
|
.search_memories(
|
||||||
|
"user",
|
||||||
|
"test-channel:user-1",
|
||||||
|
"email_folder_preference",
|
||||||
|
None,
|
||||||
|
10,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(hits.len(), 1);
|
assert_eq!(hits.len(), 1);
|
||||||
@ -585,7 +604,10 @@ fn test_memory_scope_listing_and_full_scope_read() {
|
|||||||
let scope_keys = store.list_memory_scope_keys("user").unwrap();
|
let scope_keys = store.list_memory_scope_keys("user").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scope_keys,
|
scope_keys,
|
||||||
vec!["test-channel:user-1".to_string(), "test-channel:user-2".to_string()]
|
vec![
|
||||||
|
"test-channel:user-1".to_string(),
|
||||||
|
"test-channel:user-2".to_string()
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
let full_scope = store
|
let full_scope = store
|
||||||
|
|||||||
@ -13,7 +13,7 @@ use tokio::time::{Instant, sleep_until};
|
|||||||
use crate::platform::{ShellInfo, dangerous_command_patterns};
|
use crate::platform::{ShellInfo, dangerous_command_patterns};
|
||||||
use crate::tools::shell_session::ShellSessionManager;
|
use crate::tools::shell_session::ShellSessionManager;
|
||||||
use crate::tools::traits::{Tool, ToolResult};
|
use crate::tools::traits::{Tool, ToolResult};
|
||||||
use crate::tools::{extract_u64, extract_bool, check_null_args};
|
use crate::tools::{check_null_args, extract_bool, extract_u64};
|
||||||
|
|
||||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||||
const MAX_OUTPUT_CHARS: usize = 50_000;
|
const MAX_OUTPUT_CHARS: usize = 50_000;
|
||||||
@ -84,7 +84,11 @@ impl ShellKind {
|
|||||||
/// 执行命令所需的参数
|
/// 执行命令所需的参数
|
||||||
pub fn command_args<'a>(&self, command: &'a str) -> Vec<&'a str> {
|
pub fn command_args<'a>(&self, command: &'a str) -> Vec<&'a str> {
|
||||||
let info = self.to_info();
|
let info = self.to_info();
|
||||||
info.args.iter().map(|s| *s).chain(std::iter::once(command)).collect()
|
info.args
|
||||||
|
.iter()
|
||||||
|
.map(|s| *s)
|
||||||
|
.chain(std::iter::once(command))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 工具名称
|
/// 工具名称
|
||||||
@ -95,9 +99,15 @@ impl ShellKind {
|
|||||||
/// 工具描述
|
/// 工具描述
|
||||||
pub fn tool_description(&self) -> &'static str {
|
pub fn tool_description(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
ShellKind::Bash => "Execute a bash shell command and return its output. Use with caution.",
|
ShellKind::Bash => {
|
||||||
ShellKind::PowerShell => "Execute a PowerShell command and return its output. Use with caution.",
|
"Execute a bash shell command and return its output. Use with caution."
|
||||||
ShellKind::Cmd => "Execute a cmd shell command and return its output. Use with caution.",
|
}
|
||||||
|
ShellKind::PowerShell => {
|
||||||
|
"Execute a PowerShell command and return its output. Use with caution."
|
||||||
|
}
|
||||||
|
ShellKind::Cmd => {
|
||||||
|
"Execute a cmd shell command and return its output. Use with caution."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -189,10 +199,7 @@ impl BashTool {
|
|||||||
};
|
};
|
||||||
format!(
|
format!(
|
||||||
"{}\n{}{}\n\n{}",
|
"{}\n{}{}\n\n{}",
|
||||||
PENDING_USER_ACTION_MARKER,
|
PENDING_USER_ACTION_MARKER, session_line, hint, output_section
|
||||||
session_line,
|
|
||||||
hint,
|
|
||||||
output_section
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -711,10 +718,7 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
"echo 'Hello World'"
|
"echo 'Hello World'"
|
||||||
};
|
};
|
||||||
let result = tool
|
let result = tool.execute(json!({ "command": command })).await.unwrap();
|
||||||
.execute(json!({ "command": command }))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(result.success);
|
assert!(result.success);
|
||||||
assert!(result.output.contains("Hello World"));
|
assert!(result.output.contains("Hello World"));
|
||||||
@ -742,10 +746,7 @@ mod tests {
|
|||||||
} else {
|
} else {
|
||||||
format!("ls -la {}", temp_dir.display())
|
format!("ls -la {}", temp_dir.display())
|
||||||
};
|
};
|
||||||
let result = tool
|
let result = tool.execute(json!({ "command": command })).await.unwrap();
|
||||||
.execute(json!({ "command": command }))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(result.success);
|
assert!(result.success);
|
||||||
}
|
}
|
||||||
@ -892,8 +893,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_shell_kind_command_args() {
|
fn test_shell_kind_command_args() {
|
||||||
assert_eq!(ShellKind::Bash.command_args("echo hello"), vec!["-c" as &str, "echo hello"]);
|
assert_eq!(
|
||||||
assert_eq!(ShellKind::PowerShell.command_args("echo hello"), vec!["-Command" as &str, "echo hello"]);
|
ShellKind::Bash.command_args("echo hello"),
|
||||||
assert_eq!(ShellKind::Cmd.command_args("echo hello"), vec!["/C" as &str, "echo hello"]);
|
vec!["-c" as &str, "echo hello"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ShellKind::PowerShell.command_args("echo hello"),
|
||||||
|
vec!["-Command" as &str, "echo hello"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ShellKind::Cmd.command_args("echo hello"),
|
||||||
|
vec!["/C" as &str, "echo hello"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use super::traits::{Tool, ToolResult};
|
use super::traits::{Tool, ToolResult};
|
||||||
use crate::tools::extract_f64 as extract_f64_opt;
|
|
||||||
use crate::tools::check_null_args;
|
use crate::tools::check_null_args;
|
||||||
|
use crate::tools::extract_f64 as extract_f64_opt;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@ -161,7 +161,8 @@ fn extract_f64(args: &serde_json::Value, key: &str, name: &str) -> Result<f64, S
|
|||||||
if let Some(n) = v.as_f64() {
|
if let Some(n) = v.as_f64() {
|
||||||
Ok(n)
|
Ok(n)
|
||||||
} else if let Some(s) = v.as_str() {
|
} else if let Some(s) = v.as_str() {
|
||||||
s.parse::<f64>().map_err(|_| format!("{name} is not a valid number: {s}"))
|
s.parse::<f64>()
|
||||||
|
.map_err(|_| format!("{name} is not a valid number: {s}"))
|
||||||
} else {
|
} else {
|
||||||
Err(format!("{name} must be a number"))
|
Err(format!("{name} must be a number"))
|
||||||
}
|
}
|
||||||
@ -176,7 +177,8 @@ fn extract_i64(args: &serde_json::Value, key: &str, name: &str) -> Result<i64, S
|
|||||||
if let Some(n) = v.as_i64() {
|
if let Some(n) = v.as_i64() {
|
||||||
Ok(n)
|
Ok(n)
|
||||||
} else if let Some(s) = v.as_str() {
|
} else if let Some(s) = v.as_str() {
|
||||||
s.parse::<i64>().map_err(|_| format!("{name} is not a valid integer: {s}"))
|
s.parse::<i64>()
|
||||||
|
.map_err(|_| format!("{name} is not a valid integer: {s}"))
|
||||||
} else {
|
} else {
|
||||||
Err(format!("{name} must be an integer"))
|
Err(format!("{name} must be an integer"))
|
||||||
}
|
}
|
||||||
@ -755,7 +757,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!result.success);
|
assert!(!result.success);
|
||||||
assert!(result.error.as_ref().unwrap().contains("x is not a valid number"));
|
assert!(
|
||||||
|
result
|
||||||
|
.error
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("x is not a valid number")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -763,6 +771,12 @@ mod tests {
|
|||||||
let tool = CalculatorTool::new();
|
let tool = CalculatorTool::new();
|
||||||
let result = tool.execute(serde_json::Value::Null).await.unwrap();
|
let result = tool.execute(serde_json::Value::Null).await.unwrap();
|
||||||
assert!(!result.success);
|
assert!(!result.success);
|
||||||
assert!(result.error.as_ref().unwrap().contains("Missing required parameters"));
|
assert!(
|
||||||
|
result
|
||||||
|
.error
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Missing required parameters")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ use std::path::Path;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::tools::traits::{Tool, ToolResult};
|
|
||||||
use crate::tools::extract_bool;
|
use crate::tools::extract_bool;
|
||||||
|
use crate::tools::traits::{Tool, ToolResult};
|
||||||
|
|
||||||
pub struct FileEditTool {
|
pub struct FileEditTool {
|
||||||
allowed_dir: Option<String>,
|
allowed_dir: Option<String>,
|
||||||
@ -43,21 +43,28 @@ impl FileEditTool {
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// File doesn't exist yet; canonicalize parent directory
|
// File doesn't exist yet; canonicalize parent directory
|
||||||
let parent = resolved.parent().ok_or_else(|| {
|
let parent = resolved
|
||||||
format!("Path '{}' has no parent directory", path)
|
.parent()
|
||||||
})?;
|
.ok_or_else(|| format!("Path '{}' has no parent directory", path))?;
|
||||||
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
|
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
|
||||||
format!("Failed to canonicalize parent directory of '{}': {}", path, e)
|
format!(
|
||||||
|
"Failed to canonicalize parent directory of '{}': {}",
|
||||||
|
path, e
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
canonical_parent.join(resolved.file_name().ok_or_else(|| {
|
canonical_parent.join(
|
||||||
format!("Path '{}' has no file name component", path)
|
resolved
|
||||||
})?)
|
.file_name()
|
||||||
|
.ok_or_else(|| format!("Path '{}' has no file name component", path))?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !canonical_resolved.starts_with(&canonical_allowed) {
|
if !canonical_resolved.starts_with(&canonical_allowed) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
||||||
path, canonical_resolved.display(), canonical_allowed.display()
|
path,
|
||||||
|
canonical_resolved.display(),
|
||||||
|
canonical_allowed.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,8 @@ use async_trait::async_trait;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::text::take_prefix_chars;
|
use crate::text::take_prefix_chars;
|
||||||
use crate::tools::traits::{Tool, ToolResult};
|
|
||||||
use crate::tools::extract_u64;
|
use crate::tools::extract_u64;
|
||||||
|
use crate::tools::traits::{Tool, ToolResult};
|
||||||
|
|
||||||
const MAX_CHARS: usize = 100_000;
|
const MAX_CHARS: usize = 100_000;
|
||||||
const DEFAULT_LIMIT: usize = 2000;
|
const DEFAULT_LIMIT: usize = 2000;
|
||||||
@ -48,7 +48,9 @@ impl FileReadTool {
|
|||||||
if !canonical_resolved.starts_with(&canonical_allowed) {
|
if !canonical_resolved.starts_with(&canonical_allowed) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
||||||
path, canonical_resolved.display(), canonical_allowed.display()
|
path,
|
||||||
|
canonical_resolved.display(),
|
||||||
|
canonical_allowed.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,21 +42,28 @@ impl FileWriteTool {
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// File doesn't exist yet; canonicalize parent directory
|
// File doesn't exist yet; canonicalize parent directory
|
||||||
let parent = resolved.parent().ok_or_else(|| {
|
let parent = resolved
|
||||||
format!("Path '{}' has no parent directory", path)
|
.parent()
|
||||||
})?;
|
.ok_or_else(|| format!("Path '{}' has no parent directory", path))?;
|
||||||
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
|
let canonical_parent = std::fs::canonicalize(parent).map_err(|e| {
|
||||||
format!("Failed to canonicalize parent directory of '{}': {}", path, e)
|
format!(
|
||||||
|
"Failed to canonicalize parent directory of '{}': {}",
|
||||||
|
path, e
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
canonical_parent.join(resolved.file_name().ok_or_else(|| {
|
canonical_parent.join(
|
||||||
format!("Path '{}' has no file name component", path)
|
resolved
|
||||||
})?)
|
.file_name()
|
||||||
|
.ok_or_else(|| format!("Path '{}' has no file name component", path))?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !canonical_resolved.starts_with(&canonical_allowed) {
|
if !canonical_resolved.starts_with(&canonical_allowed) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
"Path '{}' (resolves to '{}') is outside allowed directory '{}'",
|
||||||
path, canonical_resolved.display(), canonical_allowed.display()
|
path,
|
||||||
|
canonical_resolved.display(),
|
||||||
|
canonical_allowed.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::storage::{is_valid_namespace, MemoryRecord, MemoryRepository, MemoryUpsert};
|
use crate::storage::{MemoryRecord, MemoryRepository, MemoryUpsert, is_valid_namespace};
|
||||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||||
|
|
||||||
pub struct MemoryManageTool {
|
pub struct MemoryManageTool {
|
||||||
|
|||||||
@ -4,8 +4,8 @@ use async_trait::async_trait;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::storage::{MemoryRecord, MemoryRepository};
|
use crate::storage::{MemoryRecord, MemoryRepository};
|
||||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
|
||||||
use crate::tools::extract_u64;
|
use crate::tools::extract_u64;
|
||||||
|
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||||
|
|
||||||
pub struct MemorySearchTool {
|
pub struct MemorySearchTool {
|
||||||
memories: Arc<dyn MemoryRepository>,
|
memories: Arc<dyn MemoryRepository>,
|
||||||
@ -103,8 +103,7 @@ impl Tool for MemorySearchTool {
|
|||||||
Some(value) => {
|
Some(value) => {
|
||||||
// 支持两种格式:实际数组 或 字符串化的数组
|
// 支持两种格式:实际数组 或 字符串化的数组
|
||||||
if let Some(arr) = value.as_array() {
|
if let Some(arr) = value.as_array() {
|
||||||
arr
|
arr.iter()
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
.filter_map(|v| v.as_str())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
@ -133,7 +132,7 @@ impl Tool for MemorySearchTool {
|
|||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => vec![]
|
None => vec![],
|
||||||
};
|
};
|
||||||
if queries.is_empty() {
|
if queries.is_empty() {
|
||||||
return Ok(error_result("Missing required parameter: queries"));
|
return Ok(error_result("Missing required parameter: queries"));
|
||||||
|
|||||||
@ -8,8 +8,8 @@ pub mod memory_manage;
|
|||||||
pub mod memory_search;
|
pub mod memory_search;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod scheduler_manage;
|
pub mod scheduler_manage;
|
||||||
pub mod session_send;
|
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod session_send;
|
||||||
pub mod shell_session;
|
pub mod shell_session;
|
||||||
pub mod skill_activate;
|
pub mod skill_activate;
|
||||||
pub mod skill_manage;
|
pub mod skill_manage;
|
||||||
@ -30,11 +30,11 @@ pub use memory_manage::MemoryManageTool;
|
|||||||
pub use memory_search::MemorySearchTool;
|
pub use memory_search::MemorySearchTool;
|
||||||
pub use registry::ToolRegistry;
|
pub use registry::ToolRegistry;
|
||||||
pub use scheduler_manage::SchedulerManageTool;
|
pub use scheduler_manage::SchedulerManageTool;
|
||||||
|
pub use schema::{CleaningStrategy, SchemaCleanr};
|
||||||
pub use session_send::{
|
pub use session_send::{
|
||||||
NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest,
|
NoopSessionMessageSender, SessionMessageSender, SessionSendOutcome, SessionSendRequest,
|
||||||
SessionSendTool,
|
SessionSendTool,
|
||||||
};
|
};
|
||||||
pub use schema::{CleaningStrategy, SchemaCleanr};
|
|
||||||
pub use shell_session::ShellSessionManager;
|
pub use shell_session::ShellSessionManager;
|
||||||
pub use skill_activate::SkillActivateTool;
|
pub use skill_activate::SkillActivateTool;
|
||||||
pub use skill_manage::SkillManageTool;
|
pub use skill_manage::SkillManageTool;
|
||||||
@ -127,26 +127,22 @@ pub fn require_string(args: &serde_json::Value, key: &str) -> Result<String, Str
|
|||||||
|
|
||||||
/// Extract a required f64 parameter, returning an error message if missing.
|
/// Extract a required f64 parameter, returning an error message if missing.
|
||||||
pub fn require_f64(args: &serde_json::Value, key: &str) -> Result<f64, String> {
|
pub fn require_f64(args: &serde_json::Value, key: &str) -> Result<f64, String> {
|
||||||
extract_f64(args, key)
|
extract_f64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||||
.ok_or_else(|| format!("Missing required parameter: {}", key))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a required i64 parameter, returning an error message if missing.
|
/// Extract a required i64 parameter, returning an error message if missing.
|
||||||
pub fn require_i64(args: &serde_json::Value, key: &str) -> Result<i64, String> {
|
pub fn require_i64(args: &serde_json::Value, key: &str) -> Result<i64, String> {
|
||||||
extract_i64(args, key)
|
extract_i64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||||
.ok_or_else(|| format!("Missing required parameter: {}", key))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a required u64 parameter, returning an error message if missing.
|
/// Extract a required u64 parameter, returning an error message if missing.
|
||||||
pub fn require_u64(args: &serde_json::Value, key: &str) -> Result<u64, String> {
|
pub fn require_u64(args: &serde_json::Value, key: &str) -> Result<u64, String> {
|
||||||
extract_u64(args, key)
|
extract_u64(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||||
.ok_or_else(|| format!("Missing required parameter: {}", key))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a required bool parameter, returning an error message if missing.
|
/// Extract a required bool parameter, returning an error message if missing.
|
||||||
pub fn require_bool(args: &serde_json::Value, key: &str) -> Result<bool, String> {
|
pub fn require_bool(args: &serde_json::Value, key: &str) -> Result<bool, String> {
|
||||||
extract_bool(args, key)
|
extract_bool(args, key).ok_or_else(|| format!("Missing required parameter: {}", key))
|
||||||
.ok_or_else(|| format!("Missing required parameter: {}", key))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a string array parameter, handling both actual arrays and stringified JSON arrays.
|
/// Extract a string array parameter, handling both actual arrays and stringified JSON arrays.
|
||||||
@ -207,7 +203,13 @@ pub fn check_null_args(args: &serde_json::Value, tool_name: &str) -> Option<Tool
|
|||||||
error: Some(format!(
|
error: Some(format!(
|
||||||
"Invalid parameters: {} expects a JSON object, got {}",
|
"Invalid parameters: {} expects a JSON object, got {}",
|
||||||
tool_name,
|
tool_name,
|
||||||
if args.is_array() { "an array" } else if args.is_string() { "a string" } else { "an unexpected type" }
|
if args.is_array() {
|
||||||
|
"an array"
|
||||||
|
} else if args.is_string() {
|
||||||
|
"a string"
|
||||||
|
} else {
|
||||||
|
"an unexpected type"
|
||||||
|
}
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,7 +59,8 @@ impl ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_tools(&self) -> bool {
|
pub fn has_tools(&self) -> bool {
|
||||||
!self.tools
|
!self
|
||||||
|
.tools
|
||||||
.read()
|
.read()
|
||||||
.expect("ToolRegistry lock poisoned")
|
.expect("ToolRegistry lock poisoned")
|
||||||
.is_empty()
|
.is_empty()
|
||||||
@ -84,7 +85,10 @@ impl ToolRegistry {
|
|||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
let new_registry = ToolRegistry::new();
|
let new_registry = ToolRegistry::new();
|
||||||
*new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered;
|
*new_registry
|
||||||
|
.tools
|
||||||
|
.write()
|
||||||
|
.expect("ToolRegistry lock poisoned") = filtered;
|
||||||
new_registry
|
new_registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +103,10 @@ impl ToolRegistry {
|
|||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
let new_registry = ToolRegistry::new();
|
let new_registry = ToolRegistry::new();
|
||||||
*new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered;
|
*new_registry
|
||||||
|
.tools
|
||||||
|
.write()
|
||||||
|
.expect("ToolRegistry lock poisoned") = filtered;
|
||||||
new_registry
|
new_registry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,13 +132,7 @@ impl Tool for SessionSendTool {
|
|||||||
|
|
||||||
let outcome = match self
|
let outcome = match self
|
||||||
.sender
|
.sender
|
||||||
.send_to_current_session(
|
.send_to_current_session(context, SessionSendRequest { text, attachments })
|
||||||
context,
|
|
||||||
SessionSendRequest {
|
|
||||||
text,
|
|
||||||
attachments,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(outcome) => outcome,
|
Ok(outcome) => outcome,
|
||||||
@ -154,7 +148,12 @@ impl Tool for SessionSendTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_context(context: &ToolContext) -> anyhow::Result<()> {
|
fn validate_context(context: &ToolContext) -> anyhow::Result<()> {
|
||||||
if context.channel_name.as_deref().unwrap_or_default().is_empty() {
|
if context
|
||||||
|
.channel_name
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"send_session_message requires channel_name in tool context"
|
"send_session_message requires channel_name in tool context"
|
||||||
));
|
));
|
||||||
@ -413,8 +412,7 @@ fn filename_matches_target(on_disk_name: &std::ffi::OsStr, target: &str) -> bool
|
|||||||
fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>> {
|
fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>> {
|
||||||
// 支持两种格式:实际数组 或 字符串化的 JSON 数组
|
// 支持两种格式:实际数组 或 字符串化的 JSON 数组
|
||||||
let paths = if let Some(arr) = value.as_array() {
|
let paths = if let Some(arr) = value.as_array() {
|
||||||
arr
|
arr.iter()
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
.filter_map(|v| v.as_str())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
@ -565,7 +563,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(result.success);
|
assert!(result.success);
|
||||||
assert_eq!(result.output, "Sent 1 text message to the current conversation.");
|
assert_eq!(
|
||||||
|
result.output,
|
||||||
|
"Sent 1 text message to the current conversation."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -597,8 +598,8 @@ mod tests {
|
|||||||
let image_path = file.path().with_extension("png");
|
let image_path = file.path().with_extension("png");
|
||||||
std::fs::rename(file.path(), &image_path).unwrap();
|
std::fs::rename(file.path(), &image_path).unwrap();
|
||||||
|
|
||||||
let attachments = parse_attachments(&json!([image_path.to_string_lossy().to_string()]))
|
let attachments =
|
||||||
.unwrap();
|
parse_attachments(&json!([image_path.to_string_lossy().to_string()])).unwrap();
|
||||||
|
|
||||||
assert_eq!(attachments.len(), 1);
|
assert_eq!(attachments.len(), 1);
|
||||||
assert_eq!(attachments[0].media_type, "image");
|
assert_eq!(attachments[0].media_type, "image");
|
||||||
|
|||||||
@ -5,8 +5,8 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::skills::SkillRuntime;
|
use crate::skills::SkillRuntime;
|
||||||
use crate::storage::SkillEventRepository;
|
use crate::storage::SkillEventRepository;
|
||||||
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
|
||||||
use crate::tools::check_null_args;
|
use crate::tools::check_null_args;
|
||||||
|
use crate::tools::traits::{Tool, ToolContext, ToolResult};
|
||||||
|
|
||||||
pub struct SkillActivateTool {
|
pub struct SkillActivateTool {
|
||||||
skills: Arc<SkillRuntime>,
|
skills: Arc<SkillRuntime>,
|
||||||
@ -135,7 +135,9 @@ mod tests {
|
|||||||
async fn test_skill_activate_records_failed_activation_event() {
|
async fn test_skill_activate_records_failed_activation_event() {
|
||||||
let skills = Arc::new(SkillRuntime::default());
|
let skills = Arc::new(SkillRuntime::default());
|
||||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||||
store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap();
|
store
|
||||||
|
.ensure_channel_session(TEST_CHANNEL, "chat-1")
|
||||||
|
.unwrap();
|
||||||
let tool = SkillActivateTool::new(skills, store.clone());
|
let tool = SkillActivateTool::new(skills, store.clone());
|
||||||
let context = ToolContext {
|
let context = ToolContext {
|
||||||
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
||||||
@ -162,7 +164,9 @@ mod tests {
|
|||||||
async fn test_skill_activate_handles_null_args() {
|
async fn test_skill_activate_handles_null_args() {
|
||||||
let skills = Arc::new(SkillRuntime::default());
|
let skills = Arc::new(SkillRuntime::default());
|
||||||
let store = Arc::new(SessionStore::in_memory().unwrap());
|
let store = Arc::new(SessionStore::in_memory().unwrap());
|
||||||
store.ensure_channel_session(TEST_CHANNEL, "chat-1").unwrap();
|
store
|
||||||
|
.ensure_channel_session(TEST_CHANNEL, "chat-1")
|
||||||
|
.unwrap();
|
||||||
let tool = SkillActivateTool::new(skills, store.clone());
|
let tool = SkillActivateTool::new(skills, store.clone());
|
||||||
let context = ToolContext {
|
let context = ToolContext {
|
||||||
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
session_id: Some(format!("{}:chat-1", TEST_CHANNEL)),
|
||||||
@ -175,6 +179,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(!result.success);
|
assert!(!result.success);
|
||||||
assert!(result.error.unwrap().contains("Missing required parameters"));
|
assert!(
|
||||||
|
result
|
||||||
|
.error
|
||||||
|
.unwrap()
|
||||||
|
.contains("Missing required parameters")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,12 @@ pub mod types;
|
|||||||
pub use error::TaskError;
|
pub use error::TaskError;
|
||||||
pub use prompt::SubagentPromptBuilder;
|
pub use prompt::SubagentPromptBuilder;
|
||||||
pub use repository::{InMemoryTaskRepository, TaskRepository};
|
pub use repository::{InMemoryTaskRepository, TaskRepository};
|
||||||
pub use runtime::{DefaultSubAgentRuntime, SubAgentRuntime, SubAgentRuntimeConfig, SubagentCatalog, StaticSystemPromptProvider};
|
pub use runtime::{
|
||||||
|
DefaultSubAgentRuntime, StaticSystemPromptProvider, SubAgentRuntime, SubAgentRuntimeConfig,
|
||||||
|
SubagentCatalog,
|
||||||
|
};
|
||||||
pub use tool::TaskTool;
|
pub use tool::TaskTool;
|
||||||
pub use types::{SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession, TaskSessionState, TaskToolArgs, TaskToolResult};
|
pub use types::{
|
||||||
|
SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession,
|
||||||
|
TaskSessionState, TaskToolArgs, TaskToolResult,
|
||||||
|
};
|
||||||
|
|||||||
@ -7,20 +7,23 @@ use std::time::Duration;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::agent::{AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
use crate::agent::{
|
||||||
|
AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler,
|
||||||
|
SystemPrompt, SystemPromptContext, SystemPromptProvider,
|
||||||
|
};
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
use crate::bus::message::{OutboundMessage, OutboundEventKind};
|
|
||||||
use crate::bus::MessageBus;
|
use crate::bus::MessageBus;
|
||||||
use crate::domain::CapabilityPolicy;
|
use crate::bus::message::{OutboundEventKind, OutboundMessage};
|
||||||
use crate::providers::StreamDelta;
|
|
||||||
use crate::config::{LLMProviderConfig, SubagentsConfig};
|
use crate::config::{LLMProviderConfig, SubagentsConfig};
|
||||||
|
use crate::domain::CapabilityPolicy;
|
||||||
use crate::experts::ExpertRuntime;
|
use crate::experts::ExpertRuntime;
|
||||||
|
use crate::providers::StreamDelta;
|
||||||
use crate::skills::SkillRuntime;
|
use crate::skills::SkillRuntime;
|
||||||
use crate::storage::{ConversationRepository, SessionStore};
|
use crate::storage::{ConversationRepository, SessionStore};
|
||||||
use crate::tools::{ToolContext, ToolRegistry};
|
use crate::tools::{ToolContext, ToolRegistry};
|
||||||
|
|
||||||
use super::error::TaskError;
|
use super::error::TaskError;
|
||||||
use super::prompt::{extract_summary, SubagentPromptBuilder};
|
use super::prompt::{SubagentPromptBuilder, extract_summary};
|
||||||
use super::repository::TaskRepository;
|
use super::repository::TaskRepository;
|
||||||
use super::tool::TaskTool;
|
use super::tool::TaskTool;
|
||||||
use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult};
|
use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult};
|
||||||
@ -167,7 +170,9 @@ impl EmittedMessageHandler for SubAgentEmitter {
|
|||||||
async fn handle_stream_delta(&self, delta: &StreamDelta) {
|
async fn handle_stream_delta(&self, delta: &StreamDelta) {
|
||||||
let message_id = {
|
let message_id = {
|
||||||
let mut guard = self.stream_message_id.lock().unwrap();
|
let mut guard = self.stream_message_id.lock().unwrap();
|
||||||
guard.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone()
|
guard
|
||||||
|
.get_or_insert_with(|| uuid::Uuid::new_v4().to_string())
|
||||||
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() {
|
let outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() {
|
||||||
@ -289,10 +294,7 @@ fn build_subagent_event_metadata(session: &TaskSession) -> HashMap<String, Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 发布子智能体执行完成事件(ExecutionCompleted),metadata 含 subagent_task_id。
|
/// 发布子智能体执行完成事件(ExecutionCompleted),metadata 含 subagent_task_id。
|
||||||
async fn publish_subagent_completion(
|
async fn publish_subagent_completion(bus: &Option<Arc<MessageBus>>, session: &TaskSession) {
|
||||||
bus: &Option<Arc<MessageBus>>,
|
|
||||||
session: &TaskSession,
|
|
||||||
) {
|
|
||||||
if let Some(bus) = bus {
|
if let Some(bus) = bus {
|
||||||
let metadata = build_subagent_event_metadata(session);
|
let metadata = build_subagent_event_metadata(session);
|
||||||
if let Err(e) = bus
|
if let Err(e) = bus
|
||||||
@ -475,17 +477,20 @@ impl DefaultSubAgentRuntime {
|
|||||||
// 按 def 中的 provider/model 字段解析覆盖基础 provider_config。
|
// 按 def 中的 provider/model 字段解析覆盖基础 provider_config。
|
||||||
// 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。
|
// 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。
|
||||||
let effective_provider_config = match def {
|
let effective_provider_config = match def {
|
||||||
Some(d) if d.provider.is_some() || d.model.is_some() => {
|
Some(d) if d.provider.is_some() || d.model.is_some() => self
|
||||||
self.model_resolver
|
.model_resolver
|
||||||
.resolve(d.provider.as_deref(), d.model.as_deref(), &self.provider_config)
|
.resolve(
|
||||||
|
d.provider.as_deref(),
|
||||||
|
d.model.as_deref(),
|
||||||
|
&self.provider_config,
|
||||||
|
)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
TaskError::AgentCreationFailed(format!(
|
TaskError::AgentCreationFailed(format!(
|
||||||
"subagent '{}' model resolution failed: {}",
|
"subagent '{}' model resolution failed: {}",
|
||||||
def.map(|d| d.name.as_str()).unwrap_or("?"),
|
def.map(|d| d.name.as_str()).unwrap_or("?"),
|
||||||
e
|
e
|
||||||
))
|
))
|
||||||
})?
|
})?,
|
||||||
}
|
|
||||||
_ => self.provider_config.clone(),
|
_ => self.provider_config.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -519,7 +524,10 @@ impl DefaultSubAgentRuntime {
|
|||||||
let mut metadata = HashMap::new();
|
let mut metadata = HashMap::new();
|
||||||
metadata.insert("subagent_task_id".to_string(), session.id.clone());
|
metadata.insert("subagent_task_id".to_string(), session.id.clone());
|
||||||
metadata.insert("is_subagent_event".to_string(), "true".to_string());
|
metadata.insert("is_subagent_event".to_string(), "true".to_string());
|
||||||
metadata.insert("topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default());
|
metadata.insert(
|
||||||
|
"topic_id".to_string(),
|
||||||
|
session.parent_topic_id.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
|
||||||
let emitter = Arc::new(PersistingEmittedMessageHandler::new(
|
let emitter = Arc::new(PersistingEmittedMessageHandler::new(
|
||||||
SubAgentEmitter {
|
SubAgentEmitter {
|
||||||
@ -703,8 +711,14 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
let mut metadata = HashMap::new();
|
let mut metadata = HashMap::new();
|
||||||
metadata.insert("task_id".to_string(), session.id.clone());
|
metadata.insert("task_id".to_string(), session.id.clone());
|
||||||
metadata.insert("task_description".to_string(), session.description.clone());
|
metadata.insert("task_description".to_string(), session.description.clone());
|
||||||
metadata.insert("task_subagent_type".to_string(), session.subagent_type.clone());
|
metadata.insert(
|
||||||
metadata.insert("topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default());
|
"task_subagent_type".to_string(),
|
||||||
|
session.subagent_type.clone(),
|
||||||
|
);
|
||||||
|
metadata.insert(
|
||||||
|
"topic_id".to_string(),
|
||||||
|
session.parent_topic_id.clone().unwrap_or_default(),
|
||||||
|
);
|
||||||
|
|
||||||
// 如果是子智能体创建的孙智能体,传递父 task_id
|
// 如果是子智能体创建的孙智能体,传递父 task_id
|
||||||
if let Some(ref ptid) = parent_context.task_id {
|
if let Some(ref ptid) = parent_context.task_id {
|
||||||
@ -752,11 +766,17 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) {
|
let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) {
|
||||||
(true, _) | (_, true) => self
|
(true, _) | (_, true) => self
|
||||||
.model_resolver
|
.model_resolver
|
||||||
.resolve(def.provider.as_deref(), def.model.as_deref(), &self.provider_config)
|
.resolve(
|
||||||
.map_err(|e| TaskError::AgentCreationFailed(format!(
|
def.provider.as_deref(),
|
||||||
|
def.model.as_deref(),
|
||||||
|
&self.provider_config,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
TaskError::AgentCreationFailed(format!(
|
||||||
"subagent '{}' model resolution failed: {}",
|
"subagent '{}' model resolution failed: {}",
|
||||||
def.name, e
|
def.name, e
|
||||||
)))?,
|
))
|
||||||
|
})?,
|
||||||
_ => self.provider_config.clone(),
|
_ => self.provider_config.clone(),
|
||||||
};
|
};
|
||||||
let system_prompt = SubagentPromptBuilder::build(
|
let system_prompt = SubagentPromptBuilder::build(
|
||||||
@ -768,7 +788,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 7. 创建子代理
|
// 7. 创建子代理
|
||||||
let agent = self.create_subagent(&session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone())?;
|
let agent = self.create_subagent(
|
||||||
|
&session,
|
||||||
|
system_prompt,
|
||||||
|
Some(&def),
|
||||||
|
parent_context.nesting_depth,
|
||||||
|
parent_context.task_id.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
// 8. 执行任务
|
// 8. 执行任务
|
||||||
let result = self
|
let result = self
|
||||||
@ -836,7 +862,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 确保 sessions 表中存在子智能体会话记录
|
// 3. 确保 sessions 表中存在子智能体会话记录
|
||||||
let session_title = format!("Subagent [{}]: {}", session.subagent_type, session.description);
|
let session_title = format!(
|
||||||
|
"Subagent [{}]: {}",
|
||||||
|
session.subagent_type, session.description
|
||||||
|
);
|
||||||
if let Err(e) = self.conversation_repository.ensure_session(
|
if let Err(e) = self.conversation_repository.ensure_session(
|
||||||
&session.session_id,
|
&session.session_id,
|
||||||
&session.parent_channel_name,
|
&session.parent_channel_name,
|
||||||
@ -847,10 +876,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. 构建恢复提示词
|
// 4. 构建恢复提示词
|
||||||
let system_prompt = SubagentPromptBuilder::build_resume_prompt(
|
let system_prompt =
|
||||||
&session.description,
|
SubagentPromptBuilder::build_resume_prompt(&session.description, &additional_prompt);
|
||||||
&additional_prompt,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4.1 校验父智能体的子代理策略(白/黑名单)。
|
// 4.1 校验父智能体的子代理策略(白/黑名单)。
|
||||||
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
|
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
|
||||||
@ -870,7 +897,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
.map_err(TaskError::InvalidArguments)?;
|
.map_err(TaskError::InvalidArguments)?;
|
||||||
|
|
||||||
// 5. 创建子代理
|
// 5. 创建子代理
|
||||||
let agent = self.create_subagent(&session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone())?;
|
let agent = self.create_subagent(
|
||||||
|
&session,
|
||||||
|
system_prompt,
|
||||||
|
Some(&def),
|
||||||
|
parent_context.nesting_depth,
|
||||||
|
parent_context.task_id.clone(),
|
||||||
|
)?;
|
||||||
|
|
||||||
// 6. 使用历史继续执行
|
// 6. 使用历史继续执行
|
||||||
let result = self
|
let result = self
|
||||||
@ -901,7 +934,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
async fn send_message(&self, _task_id: &str, _message: String) -> Result<(), TaskError> {
|
async fn send_message(&self, _task_id: &str, _message: String) -> Result<(), TaskError> {
|
||||||
// TODO: 实现双向通信
|
// TODO: 实现双向通信
|
||||||
// 需要在 TaskSession 中添加 pending_messages 队列
|
// 需要在 TaskSession 中添加 pending_messages 队列
|
||||||
Err(TaskError::InvalidArguments("send_message not implemented yet".to_string()))
|
Err(TaskError::InvalidArguments(
|
||||||
|
"send_message not implemented yet".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_expired(&self) -> Result<usize, TaskError> {
|
async fn cleanup_expired(&self) -> Result<usize, TaskError> {
|
||||||
@ -945,7 +980,8 @@ impl SubagentCatalog {
|
|||||||
|
|
||||||
fn discover_with_cwd(config: &SubagentsConfig, cwd: &Path) -> Self {
|
fn discover_with_cwd(config: &SubagentsConfig, cwd: &Path) -> Self {
|
||||||
// 先内置作为基础
|
// 先内置作为基础
|
||||||
let mut merged: std::collections::HashMap<String, SubagentDef> = std::collections::HashMap::new();
|
let mut merged: std::collections::HashMap<String, SubagentDef> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
merged.insert("general".to_string(), SubagentDef::builtin_general());
|
merged.insert("general".to_string(), SubagentDef::builtin_general());
|
||||||
|
|
||||||
tracing::debug!(cwd = %cwd.display(), "Discovering subagents from cwd");
|
tracing::debug!(cwd = %cwd.display(), "Discovering subagents from cwd");
|
||||||
@ -1023,7 +1059,7 @@ impl SubagentCatalog {
|
|||||||
"# 子代理系统\n\n\
|
"# 子代理系统\n\n\
|
||||||
子代理是专用的执行单元,用于处理特定类型的任务。\n\
|
子代理是专用的执行单元,用于处理特定类型的任务。\n\
|
||||||
创建子代理任务时,可以选择以下类型之一:\n\n\
|
创建子代理任务时,可以选择以下类型之一:\n\n\
|
||||||
<available_subagents>\n"
|
<available_subagents>\n",
|
||||||
);
|
);
|
||||||
|
|
||||||
for def in defs {
|
for def in defs {
|
||||||
@ -1223,15 +1259,24 @@ impl SubagentRuntime {
|
|||||||
/// 重新发现子代理并替换内存 catalog(写回 SUBAGENT.md 后调用)。
|
/// 重新发现子代理并替换内存 catalog(写回 SUBAGENT.md 后调用)。
|
||||||
pub fn reload(&self) -> Result<(), String> {
|
pub fn reload(&self) -> Result<(), String> {
|
||||||
let new_catalog = SubagentCatalog::discover(&self.config);
|
let new_catalog = SubagentCatalog::discover(&self.config);
|
||||||
let mut guard = self.catalog.write().expect("subagent catalog rwlock poisoned");
|
let mut guard = self
|
||||||
|
.catalog
|
||||||
|
.write()
|
||||||
|
.expect("subagent catalog rwlock poisoned");
|
||||||
*guard = new_catalog;
|
*guard = new_catalog;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列出所有子代理(含禁用项),带 disabled_in_scopes
|
/// 列出所有子代理(含禁用项),带 disabled_in_scopes
|
||||||
pub fn list_with_status(&self) -> Vec<SubagentWithStatus> {
|
pub fn list_with_status(&self) -> Vec<SubagentWithStatus> {
|
||||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
let state = self
|
||||||
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
|
.disable_state
|
||||||
|
.read()
|
||||||
|
.expect("subagent state rwlock poisoned");
|
||||||
|
let catalog = self
|
||||||
|
.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned");
|
||||||
let mut items: Vec<SubagentWithStatus> = catalog
|
let mut items: Vec<SubagentWithStatus> = catalog
|
||||||
.all()
|
.all()
|
||||||
.iter()
|
.iter()
|
||||||
@ -1254,8 +1299,14 @@ impl SubagentRuntime {
|
|||||||
|
|
||||||
/// 可用子代理名称(过滤禁用项)
|
/// 可用子代理名称(过滤禁用项)
|
||||||
pub fn available_names(&self) -> Vec<String> {
|
pub fn available_names(&self) -> Vec<String> {
|
||||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
let state = self
|
||||||
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
|
.disable_state
|
||||||
|
.read()
|
||||||
|
.expect("subagent state rwlock poisoned");
|
||||||
|
let catalog = self
|
||||||
|
.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned");
|
||||||
catalog
|
catalog
|
||||||
.names()
|
.names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -1265,17 +1316,30 @@ impl SubagentRuntime {
|
|||||||
|
|
||||||
/// 查找可用子代理(过滤禁用项)
|
/// 查找可用子代理(过滤禁用项)
|
||||||
pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
|
pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
|
||||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
let state = self
|
||||||
|
.disable_state
|
||||||
|
.read()
|
||||||
|
.expect("subagent state rwlock poisoned");
|
||||||
if state.is_disabled(name) {
|
if state.is_disabled(name) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.catalog.read().expect("subagent catalog rwlock poisoned").find(name).cloned()
|
self.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned")
|
||||||
|
.find(name)
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成过滤后的系统索引提示词
|
/// 生成过滤后的系统索引提示词
|
||||||
pub fn system_index_prompt_filtered(&self) -> Option<String> {
|
pub fn system_index_prompt_filtered(&self) -> Option<String> {
|
||||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
let state = self
|
||||||
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
|
.disable_state
|
||||||
|
.read()
|
||||||
|
.expect("subagent state rwlock poisoned");
|
||||||
|
let catalog = self
|
||||||
|
.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned");
|
||||||
let available_defs: Vec<&SubagentDef> = catalog
|
let available_defs: Vec<&SubagentDef> = catalog
|
||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -1313,8 +1377,14 @@ impl SubagentRuntime {
|
|||||||
allowed: Option<&[String]>,
|
allowed: Option<&[String]>,
|
||||||
denied: &[String],
|
denied: &[String],
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
let state = self
|
||||||
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
|
.disable_state
|
||||||
|
.read()
|
||||||
|
.expect("subagent state rwlock poisoned");
|
||||||
|
let catalog = self
|
||||||
|
.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned");
|
||||||
let available_defs: Vec<&SubagentDef> = catalog
|
let available_defs: Vec<&SubagentDef> = catalog
|
||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -1377,7 +1447,13 @@ impl SubagentRuntime {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<SubagentAvailabilityChange, String> {
|
) -> Result<SubagentAvailabilityChange, String> {
|
||||||
// 校验子代理存在
|
// 校验子代理存在
|
||||||
if self.catalog.read().expect("subagent catalog rwlock poisoned").find(name).is_none() {
|
if self
|
||||||
|
.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned")
|
||||||
|
.find(name)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
return Err(format!("subagent '{}' not found", name));
|
return Err(format!("subagent '{}' not found", name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1452,7 +1528,10 @@ impl SubagentRuntime {
|
|||||||
reload: bool,
|
reload: bool,
|
||||||
) -> Result<SubagentDef, String> {
|
) -> Result<SubagentDef, String> {
|
||||||
let def = {
|
let def = {
|
||||||
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
|
let catalog = self
|
||||||
|
.catalog
|
||||||
|
.read()
|
||||||
|
.expect("subagent catalog rwlock poisoned");
|
||||||
catalog
|
catalog
|
||||||
.find(name)
|
.find(name)
|
||||||
.ok_or_else(|| format!("subagent '{}' not found", name))?
|
.ok_or_else(|| format!("subagent '{}' not found", name))?
|
||||||
@ -1471,7 +1550,9 @@ impl SubagentRuntime {
|
|||||||
|
|
||||||
let next_description = description.unwrap_or(&def.description);
|
let next_description = description.unwrap_or(&def.description);
|
||||||
let next_body = body.unwrap_or(def.body.as_deref().unwrap_or(""));
|
let next_body = body.unwrap_or(def.body.as_deref().unwrap_or(""));
|
||||||
let next_capability = capability.cloned().unwrap_or_else(|| def.capability.clone());
|
let next_capability = capability
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| def.capability.clone());
|
||||||
let next_provider = provider.cloned().unwrap_or(def.provider);
|
let next_provider = provider.cloned().unwrap_or(def.provider);
|
||||||
let next_model = model.cloned().unwrap_or(def.model);
|
let next_model = model.cloned().unwrap_or(def.model);
|
||||||
|
|
||||||
@ -1516,15 +1597,14 @@ impl SystemPromptProvider for SubagentPromptProvider {
|
|||||||
// 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认)
|
// 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认)
|
||||||
let content = match context.session_id.as_deref() {
|
let content = match context.session_id.as_deref() {
|
||||||
Some(sid) => {
|
Some(sid) => {
|
||||||
let policy = self
|
let policy = self.experts.selected_expert_for(sid).map(|e| e.capability);
|
||||||
.experts
|
|
||||||
.selected_expert_for(sid)
|
|
||||||
.map(|e| e.capability);
|
|
||||||
match policy {
|
match policy {
|
||||||
Some(p) if p.has_subagent_policy() => self.runtime.system_index_prompt_filtered_with_policy(
|
Some(p) if p.has_subagent_policy() => {
|
||||||
|
self.runtime.system_index_prompt_filtered_with_policy(
|
||||||
p.allowed_subagents.as_deref(),
|
p.allowed_subagents.as_deref(),
|
||||||
&p.denied_subagents,
|
&p.denied_subagents,
|
||||||
),
|
)
|
||||||
|
}
|
||||||
_ => self.runtime.system_index_prompt_filtered(),
|
_ => self.runtime.system_index_prompt_filtered(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1671,8 +1751,7 @@ fn load_subagents_from_root(root: &Path, source: SubagentSource) -> Vec<Subagent
|
|||||||
|
|
||||||
/// 解析子代理文件
|
/// 解析子代理文件
|
||||||
fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDef, String> {
|
fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDef, String> {
|
||||||
let content = fs::read_to_string(path)
|
let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
|
||||||
.map_err(|e| format!("failed to read file: {}", e))?;
|
|
||||||
|
|
||||||
let (frontmatter, body) = match crate::frontmatter::parse::<SubagentFrontmatter>(&content) {
|
let (frontmatter, body) = match crate::frontmatter::parse::<SubagentFrontmatter>(&content) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@ -1695,7 +1774,11 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
|
|||||||
.unwrap_or_else(|| "unknown-subagent".to_string());
|
.unwrap_or_else(|| "unknown-subagent".to_string());
|
||||||
|
|
||||||
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
|
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
|
||||||
let prompt_template = frontmatter.prompt_template.unwrap_or_default().trim().to_string();
|
let prompt_template = frontmatter
|
||||||
|
.prompt_template
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
let body_content = body.trim().to_string();
|
let body_content = body.trim().to_string();
|
||||||
|
|
||||||
let capability = CapabilityPolicy {
|
let capability = CapabilityPolicy {
|
||||||
@ -1720,7 +1803,11 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
|
|||||||
name,
|
name,
|
||||||
description: frontmatter.description.trim().to_string(),
|
description: frontmatter.description.trim().to_string(),
|
||||||
prompt_template,
|
prompt_template,
|
||||||
body: if body_content.is_empty() { None } else { Some(body_content) },
|
body: if body_content.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(body_content)
|
||||||
|
},
|
||||||
capability,
|
capability,
|
||||||
max_execution_secs: frontmatter.max_execution_secs,
|
max_execution_secs: frontmatter.max_execution_secs,
|
||||||
source,
|
source,
|
||||||
@ -1948,9 +2035,7 @@ mod tests {
|
|||||||
|
|
||||||
let items = runtime.list_with_status();
|
let items = runtime.list_with_status();
|
||||||
let general = items.iter().find(|i| i.name == "general").unwrap();
|
let general = items.iter().find(|i| i.name == "general").unwrap();
|
||||||
assert!(general
|
assert!(general.disabled_in_scopes.contains(&"project".to_string()));
|
||||||
.disabled_in_scopes
|
|
||||||
.contains(&"project".to_string()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -2059,10 +2144,7 @@ mod tests {
|
|||||||
let base = base_registry();
|
let base = base_registry();
|
||||||
let p = policy(None, &[]);
|
let p = policy(None, &[]);
|
||||||
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true);
|
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true);
|
||||||
assert_eq!(
|
assert_eq!(sorted_names(®), vec!["bash", "edit", "read", "write"]);
|
||||||
sorted_names(®),
|
|
||||||
vec!["bash", "edit", "read", "write"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -2158,10 +2240,7 @@ mod tests {
|
|||||||
def.capability.allowed_skills.as_deref(),
|
def.capability.allowed_skills.as_deref(),
|
||||||
Some(["skill_a".to_string(), "skill_b".to_string()].as_slice())
|
Some(["skill_a".to_string(), "skill_b".to_string()].as_slice())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(def.capability.denied_skills, vec!["skill_c".to_string()]);
|
||||||
def.capability.denied_skills,
|
|
||||||
vec!["skill_c".to_string()]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
def.capability.allowed_tools.as_deref(),
|
def.capability.allowed_tools.as_deref(),
|
||||||
Some(["read".to_string(), "todo_write".to_string()].as_slice())
|
Some(["read".to_string(), "todo_write".to_string()].as_slice())
|
||||||
@ -2273,16 +2352,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn render_subagent_file_omits_empty_capability() {
|
fn render_subagent_file_omits_empty_capability() {
|
||||||
let cap = CapabilityPolicy::default();
|
let cap = CapabilityPolicy::default();
|
||||||
let content = render_subagent_file(
|
let content =
|
||||||
"basic",
|
render_subagent_file("basic", "basic agent", "", "body", &cap, None, &None, &None)
|
||||||
"basic agent",
|
|
||||||
"",
|
|
||||||
"body",
|
|
||||||
&cap,
|
|
||||||
None,
|
|
||||||
&None,
|
|
||||||
&None,
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// 空 capability 字段不应出现在 YAML 中
|
// 空 capability 字段不应出现在 YAML 中
|
||||||
assert!(!content.contains("allowed_skills"));
|
assert!(!content.contains("allowed_skills"));
|
||||||
@ -2327,10 +2398,21 @@ mod tests {
|
|||||||
denied_subagents: vec![],
|
denied_subagents: vec![],
|
||||||
};
|
};
|
||||||
let updated = runtime
|
let updated = runtime
|
||||||
.update_subagent("demo", Some("updated desc"), None, Some(&new_cap), Some(&None), Some(&None), false)
|
.update_subagent(
|
||||||
|
"demo",
|
||||||
|
Some("updated desc"),
|
||||||
|
None,
|
||||||
|
Some(&new_cap),
|
||||||
|
Some(&None),
|
||||||
|
Some(&None),
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(updated.description, "updated desc");
|
assert_eq!(updated.description, "updated desc");
|
||||||
assert_eq!(updated.capability.denied_skills, vec!["skill_x".to_string()]);
|
assert_eq!(
|
||||||
|
updated.capability.denied_skills,
|
||||||
|
vec!["skill_x".to_string()]
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
updated.capability.allowed_tools.as_deref(),
|
updated.capability.allowed_tools.as_deref(),
|
||||||
Some(["read".to_string()].as_slice())
|
Some(["read".to_string()].as_slice())
|
||||||
@ -2339,7 +2421,10 @@ mod tests {
|
|||||||
// 重新从文件 parse 验证写回成功
|
// 重新从文件 parse 验证写回成功
|
||||||
let reparsed = parse_subagent_file(&path, SubagentSource::Project).unwrap();
|
let reparsed = parse_subagent_file(&path, SubagentSource::Project).unwrap();
|
||||||
assert_eq!(reparsed.description, "updated desc");
|
assert_eq!(reparsed.description, "updated desc");
|
||||||
assert_eq!(reparsed.capability.denied_skills, vec!["skill_x".to_string()]);
|
assert_eq!(
|
||||||
|
reparsed.capability.denied_skills,
|
||||||
|
vec!["skill_x".to_string()]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -107,9 +107,7 @@ impl Tool for TaskTool {
|
|||||||
return Ok(ToolResult {
|
return Ok(ToolResult {
|
||||||
success: false,
|
success: false,
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
error: Some(
|
error: Some("description should be 1-5 words, max 50 characters".to_string()),
|
||||||
"description should be 1-5 words, max 50 characters".to_string(),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,9 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Days, Duration, Months, Utc};
|
use chrono::{DateTime, Days, Duration, Months, Utc};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::tools::extract_u64;
|
|
||||||
use crate::tools::check_null_args;
|
|
||||||
use super::traits::{Tool, ToolResult};
|
use super::traits::{Tool, ToolResult};
|
||||||
|
use crate::tools::check_null_args;
|
||||||
|
use crate::tools::extract_u64;
|
||||||
|
|
||||||
pub struct TimeTool {
|
pub struct TimeTool {
|
||||||
default_timezone: String,
|
default_timezone: String,
|
||||||
@ -468,7 +468,11 @@ mod tests {
|
|||||||
json!({"direction": "future", "amount": "7", "unit": "days"}),
|
json!({"direction": "future", "amount": "7", "unit": "days"}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(result.success, "Expected success but got error: {:?}", result.error);
|
assert!(
|
||||||
|
result.success,
|
||||||
|
"Expected success but got error: {:?}",
|
||||||
|
result.error
|
||||||
|
);
|
||||||
let payload: Value = serde_json::from_str(&result.output).unwrap();
|
let payload: Value = serde_json::from_str(&result.output).unwrap();
|
||||||
assert_eq!(payload["result_time"], "2026-05-04T12:30:00+08:00");
|
assert_eq!(payload["result_time"], "2026-05-04T12:30:00+08:00");
|
||||||
assert_eq!(payload["offset"]["amount"], 7);
|
assert_eq!(payload["offset"]["amount"], 7);
|
||||||
@ -483,7 +487,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(!result.success);
|
assert!(!result.success);
|
||||||
assert!(result.error.as_deref().unwrap().contains("Missing required parameter: amount"));
|
assert!(
|
||||||
|
result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Missing required parameter: amount")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -493,7 +503,13 @@ mod tests {
|
|||||||
|
|
||||||
// Null args should return error (current time requires no params, but null is invalid)
|
// Null args should return error (current time requires no params, but null is invalid)
|
||||||
assert!(!result.success);
|
assert!(!result.success);
|
||||||
assert!(result.error.as_deref().unwrap().contains("Missing required parameters"));
|
assert!(
|
||||||
|
result
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("Missing required parameters")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user