refactor(code-quality): 清理 clippy 存量告警(unwrap/clone/redundant 等)
- 移除无用克隆与冗余引用,减少不必要内存分配 - 规范 unwrap/expect 使用,修复可提前失败路径 - 修复 anthropic provider llm_timeout_secs 死代码并补全超时日志 - cargo fmt 统一格式
This commit is contained in:
parent
3faefc74b9
commit
1019dbe8cc
@ -337,15 +337,14 @@ fn filter_images_by_age_and_count(
|
|||||||
.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 {
|
if exceeds_age_limit {
|
||||||
format!(
|
format!(
|
||||||
"{} [图片已过期:超出 {} 条消息范围]",
|
"{} [图片已过期:超出 {} 条消息范围]",
|
||||||
message.content, max_age_rounds
|
message.content, max_age_rounds
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{} [图片已过期:超出最大图片数量限制]", message.content)
|
format!("{} [图片已过期:超出最大图片数量限制]", message.content)
|
||||||
};
|
}
|
||||||
notice
|
|
||||||
} else {
|
} else {
|
||||||
message.content.clone()
|
message.content.clone()
|
||||||
};
|
};
|
||||||
@ -614,7 +613,7 @@ impl LoopDetector {
|
|||||||
.count();
|
.count();
|
||||||
|
|
||||||
// Warn every warn_every times
|
// Warn every warn_every times
|
||||||
if consecutive > 0 && consecutive % self.config.warn_every == 0 {
|
if consecutive > 0 && consecutive.is_multiple_of(self.config.warn_every) {
|
||||||
LoopDetectionResult::Warning(format!(
|
LoopDetectionResult::Warning(format!(
|
||||||
"注意: 工具 '{}' 已连续执行 {} 次,参数相同。如果任务没有进展,请尝试其他方法。",
|
"注意: 工具 '{}' 已连续执行 {} 次,参数相同。如果任务没有进展,请尝试其他方法。",
|
||||||
last.name, consecutive
|
last.name, consecutive
|
||||||
@ -1139,7 +1138,7 @@ impl AgentLoop {
|
|||||||
// 避免每轮 serde_json::to_string 全量序列化工具定义。
|
// 避免每轮 serde_json::to_string 全量序列化工具定义。
|
||||||
let tools_tokens = tools
|
let tools_tokens = tools
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|t| estimate_tokens_from_serialized_json(t))
|
.map(estimate_tokens_from_serialized_json)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
for iteration in 0..self.max_iterations {
|
for iteration in 0..self.max_iterations {
|
||||||
@ -1513,8 +1512,9 @@ impl AgentLoop {
|
|||||||
.and_then(|m| m.usage.as_ref())
|
.and_then(|m| m.usage.as_ref())
|
||||||
.map(|u| u.prompt_tokens);
|
.map(|u| u.prompt_tokens);
|
||||||
|
|
||||||
if let Some(prompt_tokens) = last_prompt_tokens {
|
if let Some(prompt_tokens) = last_prompt_tokens
|
||||||
if compressor.should_compress_by_usage(prompt_tokens) {
|
&& compressor.should_compress_by_usage(prompt_tokens)
|
||||||
|
{
|
||||||
// 阶段 1a:工程化压缩(截断非子代理 tool 结果,仅改内存)
|
// 阶段 1a:工程化压缩(截断非子代理 tool 结果,仅改内存)
|
||||||
// 参数内聚到 ContextCompressor,AgentLoop 不持有截断 token 数
|
// 参数内聚到 ContextCompressor,AgentLoop 不持有截断 token 数
|
||||||
compressor.truncate_tool_results(&mut messages);
|
compressor.truncate_tool_results(&mut messages);
|
||||||
@ -1527,8 +1527,7 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 阶段 1b:重新估算,判断是否需要 LLM 压缩(30% 阈值)
|
// 阶段 1b:重新估算,判断是否需要 LLM 压缩(30% 阈值)
|
||||||
let estimated =
|
let estimated = crate::agent::context_compressor::estimate_tokens(&messages);
|
||||||
crate::agent::context_compressor::estimate_tokens(&messages);
|
|
||||||
if estimated > compressor.llm_compaction_threshold() {
|
if estimated > compressor.llm_compaction_threshold() {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
iteration,
|
iteration,
|
||||||
@ -1538,17 +1537,15 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
// LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop
|
// LLM 压缩失败时降级为仅工程化压缩,不中断 agent loop
|
||||||
match compressor
|
match compressor
|
||||||
.compress_two_segment_with_provider(
|
.compress_two_segment_with_provider(&messages, self.provider.as_ref())
|
||||||
&messages,
|
|
||||||
self.provider.as_ref(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(compressed) => {
|
Ok(compressed) => {
|
||||||
// sink 失败时记日志但不中断——内存已压缩,DB 未更新
|
// sink 失败时记日志但不中断——内存已压缩,DB 未更新
|
||||||
// 下次 process 从 DB 加载时会重新触发压缩
|
// 下次 process 从 DB 加载时会重新触发压缩
|
||||||
if let Some(sink) = compaction_sink {
|
if let Some(sink) = compaction_sink
|
||||||
if let Err(e) = sink.compact(&compressed).await {
|
&& let Err(e) = sink.compact(&compressed).await
|
||||||
|
{
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %e,
|
error = %e,
|
||||||
iteration,
|
iteration,
|
||||||
@ -1556,7 +1553,6 @@ impl AgentLoop {
|
|||||||
in-memory messages still replaced, DB will be re-compacted next round"
|
in-memory messages still replaced, DB will be re-compacted next round"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
messages = compressed;
|
messages = compressed;
|
||||||
compaction_performed = true;
|
compaction_performed = true;
|
||||||
}
|
}
|
||||||
@ -1580,7 +1576,6 @@ impl AgentLoop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Loop continues to next iteration with updated messages
|
// Loop continues to next iteration with updated messages
|
||||||
// PendingUserAction 工具的结果已在上方加入 messages,
|
// PendingUserAction 工具的结果已在上方加入 messages,
|
||||||
@ -2319,14 +2314,14 @@ mod tests {
|
|||||||
fn test_should_execute_in_parallel_single_tool() {
|
fn test_should_execute_in_parallel_single_tool() {
|
||||||
// Would need a proper setup with AgentLoop to test fully
|
// Would need a proper setup with AgentLoop to test fully
|
||||||
// For now, just verify the logic: single tool should return false
|
// For now, just verify the logic: single tool should return false
|
||||||
let calls = vec![ToolCall {
|
let calls = [ToolCall {
|
||||||
id: "1".to_string(),
|
id: "1".to_string(),
|
||||||
name: "test".to_string(),
|
name: "test".to_string(),
|
||||||
arguments: serde_json::json!({}),
|
arguments: serde_json::json!({}),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
// If there's only 1 tool, should return false regardless
|
// If there's only 1 tool, should return false regardless
|
||||||
assert_eq!(calls.len() <= 1, true);
|
assert!(calls.len() <= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -2619,9 +2614,15 @@ mod tests {
|
|||||||
let filtered = filter_images_by_age_and_count(&messages, 10, 3);
|
let filtered = filter_images_by_age_and_count(&messages, 10, 3);
|
||||||
|
|
||||||
// 检查结果
|
// 检查结果
|
||||||
assert!(filtered[19].media_refs.len() > 0, "最新消息应保留图片");
|
assert!(!filtered[19].media_refs.is_empty(), "最新消息应保留图片");
|
||||||
assert!(filtered[15].media_refs.len() > 0, "age=4 的消息应保留图片");
|
assert!(
|
||||||
assert!(filtered[10].media_refs.len() > 0, "age=9 的消息应保留图片");
|
!filtered[15].media_refs.is_empty(),
|
||||||
|
"age=4 的消息应保留图片"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!filtered[10].media_refs.is_empty(),
|
||||||
|
"age=9 的消息应保留图片"
|
||||||
|
);
|
||||||
assert_eq!(filtered[5].media_refs.len(), 0, "age=14 的消息图片应被过滤");
|
assert_eq!(filtered[5].media_refs.len(), 0, "age=14 的消息图片应被过滤");
|
||||||
assert!(filtered[5].content.contains("超出 10 条消息范围"));
|
assert!(filtered[5].content.contains("超出 10 条消息范围"));
|
||||||
assert_eq!(filtered[0].media_refs.len(), 0, "age=19 的消息图片应被过滤");
|
assert_eq!(filtered[0].media_refs.len(), 0, "age=19 的消息图片应被过滤");
|
||||||
@ -3117,7 +3118,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
messages
|
messages
|
||||||
.iter()
|
.iter()
|
||||||
.all(|m| m.tool_calls.as_ref().map_or(true, |c| c.is_empty())),
|
.all(|m| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
|
||||||
"no assistant should have tool_calls remaining"
|
"no assistant should have tool_calls remaining"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,7 +54,7 @@ fn is_assistant_with_tool_calls(msg: &ChatMessage) -> bool {
|
|||||||
&& msg
|
&& msg
|
||||||
.tool_calls
|
.tool_calls
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(false, |calls| !calls.is_empty())
|
.is_some_and(|calls| !calls.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a flat message list into atomic units. Orphaned tool results
|
/// Parse a flat message list into atomic units. Orphaned tool results
|
||||||
@ -713,10 +713,8 @@ OLDER SEGMENT (events from earlier in the session):
|
|||||||
let middle_units = &compressible[preserve_count..split];
|
let middle_units = &compressible[preserve_count..split];
|
||||||
|
|
||||||
// Step 4: Build middle segment messages and transcript
|
// Step 4: Build middle segment messages and transcript
|
||||||
let middle_messages: Vec<ChatMessage> = middle_units
|
let middle_messages: Vec<ChatMessage> =
|
||||||
.iter()
|
middle_units.iter().flat_map(unit_to_messages).collect();
|
||||||
.flat_map(unit_to_messages)
|
|
||||||
.collect();
|
|
||||||
let middle_transcript = Self::build_transcript(&middle_messages);
|
let middle_transcript = Self::build_transcript(&middle_messages);
|
||||||
|
|
||||||
// Step 5: Summarize middle segment with LLM (heavy prompt)
|
// Step 5: Summarize middle segment with LLM (heavy prompt)
|
||||||
@ -1116,8 +1114,8 @@ mod tests {
|
|||||||
fn test_chinese_tokens_higher_than_english() {
|
fn test_chinese_tokens_higher_than_english() {
|
||||||
// Use more characters to make the content difference significant
|
// Use more characters to make the content difference significant
|
||||||
// compared to JSON overhead (50 tokens per message)
|
// compared to JSON overhead (50 tokens per message)
|
||||||
let english = vec![ChatMessage::user(&"abcdefghij".repeat(20))]; // 200 English chars
|
let english = vec![ChatMessage::user("abcdefghij".repeat(20))]; // 200 English chars
|
||||||
let chinese = vec![ChatMessage::user(&"这是一个测试消息字".repeat(20))]; // 200 CJK chars (10 chars * 20)
|
let chinese = vec![ChatMessage::user("这是一个测试消息字".repeat(20))]; // 200 CJK chars (10 chars * 20)
|
||||||
|
|
||||||
let english_tokens = estimate_tokens(&english);
|
let english_tokens = estimate_tokens(&english);
|
||||||
let chinese_tokens = estimate_tokens(&chinese);
|
let chinese_tokens = estimate_tokens(&chinese);
|
||||||
@ -1153,7 +1151,7 @@ mod tests {
|
|||||||
let compressor = ContextCompressor::new(20);
|
let compressor = ContextCompressor::new(20);
|
||||||
// Need more content to trigger compression with new weighted calculation
|
// Need more content to trigger compression with new weighted calculation
|
||||||
// 200 English chars / 4 = 50 tokens, plus overhead
|
// 200 English chars / 4 = 50 tokens, plus overhead
|
||||||
let messages = vec![ChatMessage::user(&"x".repeat(400))];
|
let messages = vec![ChatMessage::user("x".repeat(400))];
|
||||||
assert!(compressor.should_compress(&messages));
|
assert!(compressor.should_compress(&messages));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1257,7 +1255,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_chunk_messages_for_summary_splits_oversized_message() {
|
fn test_chunk_messages_for_summary_splits_oversized_message() {
|
||||||
let messages = vec![ChatMessage::user(&"x".repeat(25))];
|
let messages = vec![ChatMessage::user("x".repeat(25))];
|
||||||
|
|
||||||
let chunks = ContextCompressor::chunk_messages_for_summary(&messages, 10);
|
let chunks = ContextCompressor::chunk_messages_for_summary(&messages, 10);
|
||||||
|
|
||||||
|
|||||||
@ -321,17 +321,17 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
|||||||
for i in (0..messages.len()).rev() {
|
for i in (0..messages.len()).rev() {
|
||||||
let msg = &messages[i];
|
let msg = &messages[i];
|
||||||
|
|
||||||
if msg.role == "tool" {
|
if msg.role == "tool"
|
||||||
if let Some(ref tc_id) = msg.tool_call_id {
|
&& let Some(ref tc_id) = msg.tool_call_id
|
||||||
|
{
|
||||||
resolved_ids.insert(tc_id.clone());
|
resolved_ids.insert(tc_id.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if msg.role == "assistant"
|
if msg.role == "assistant"
|
||||||
&& msg
|
&& msg
|
||||||
.tool_calls
|
.tool_calls
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(false, |calls| !calls.is_empty())
|
.is_some_and(|calls| !calls.is_empty())
|
||||||
{
|
{
|
||||||
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
||||||
let all_have_results = tool_calls.iter().all(|tc| resolved_ids.contains(&tc.id));
|
let all_have_results = tool_calls.iter().all(|tc| resolved_ids.contains(&tc.id));
|
||||||
@ -379,8 +379,9 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
|||||||
// If we have pending tool_ids and encounter a non-tool message,
|
// If we have pending tool_ids and encounter a non-tool message,
|
||||||
// the assistant's tool results were NOT immediately following.
|
// the assistant's tool results were NOT immediately following.
|
||||||
if !pending_tool_ids.is_empty() && m.role != "tool" {
|
if !pending_tool_ids.is_empty() && m.role != "tool" {
|
||||||
if let Some(idx) = pending_assistant_idx {
|
if let Some(idx) = pending_assistant_idx
|
||||||
if !remove_indices.contains(&idx) {
|
&& !remove_indices.contains(&idx)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
message_index = idx,
|
message_index = idx,
|
||||||
interrupted_by_index = i,
|
interrupted_by_index = i,
|
||||||
@ -398,15 +399,11 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
|||||||
}
|
}
|
||||||
remove_indices.push(idx);
|
remove_indices.push(idx);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
pending_tool_ids.clear();
|
pending_tool_ids.clear();
|
||||||
pending_assistant_idx = None;
|
pending_assistant_idx = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.role == "assistant"
|
if m.role == "assistant" && m.tool_calls.as_ref().is_some_and(|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 {
|
||||||
@ -419,20 +416,21 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
|||||||
.collect();
|
.collect();
|
||||||
pending_assistant_idx = Some(i);
|
pending_assistant_idx = Some(i);
|
||||||
}
|
}
|
||||||
} else if m.role == "tool" {
|
} else if m.role == "tool"
|
||||||
if let Some(ref tc_id) = m.tool_call_id {
|
&& let Some(ref tc_id) = m.tool_call_id
|
||||||
|
{
|
||||||
pending_tool_ids.remove(tc_id);
|
pending_tool_ids.remove(tc_id);
|
||||||
if pending_tool_ids.is_empty() {
|
if pending_tool_ids.is_empty() {
|
||||||
pending_assistant_idx = None;
|
pending_assistant_idx = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Handle trailing assistant with unresolved immediate tool results
|
// Handle trailing assistant with unresolved immediate tool results
|
||||||
if !pending_tool_ids.is_empty() {
|
if !pending_tool_ids.is_empty()
|
||||||
if let Some(idx) = pending_assistant_idx {
|
&& let Some(idx) = pending_assistant_idx
|
||||||
if !remove_indices.contains(&idx) {
|
&& !remove_indices.contains(&idx)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
message_index = idx,
|
message_index = idx,
|
||||||
"Removing trailing assistant with incomplete immediate tool results"
|
"Removing trailing assistant with incomplete immediate tool results"
|
||||||
@ -445,8 +443,6 @@ pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMes
|
|||||||
remove_indices.push(idx);
|
remove_indices.push(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove in descending index order to avoid shifting.
|
// Remove in descending index order to avoid shifting.
|
||||||
// 两阶段产出的索引并非全局降序:Phase 1(反向扫描)按降序追加,
|
// 两阶段产出的索引并非全局降序:Phase 1(反向扫描)按降序追加,
|
||||||
@ -939,7 +935,7 @@ fn format_tool_arguments_json(value: &serde_json::Value) -> String {
|
|||||||
match value {
|
match value {
|
||||||
serde_json::Value::Object(map) => {
|
serde_json::Value::Object(map) => {
|
||||||
let mut entries: Vec<_> = map.iter().collect();
|
let mut entries: Vec<_> = map.iter().collect();
|
||||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
entries.sort_by_key(|(left, _)| *left);
|
||||||
let body = entries
|
let body = entries
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(key, value)| {
|
.map(|(key, value)| {
|
||||||
|
|||||||
@ -234,12 +234,12 @@ impl FeishuChannel {
|
|||||||
// 1. Check cache
|
// 1. Check cache
|
||||||
{
|
{
|
||||||
let cached = self.tenant_token.read().await;
|
let cached = self.tenant_token.read().await;
|
||||||
if let Some(ref token) = *cached {
|
if let Some(ref token) = *cached
|
||||||
if Instant::now() < token.refresh_after {
|
&& Instant::now() < token.refresh_after
|
||||||
|
{
|
||||||
return Ok(token.value.clone());
|
return Ok(token.value.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch new token
|
// 2. Fetch new token
|
||||||
let (token, ttl) = self.fetch_new_token().await?;
|
let (token, ttl) = self.fetch_new_token().await?;
|
||||||
@ -1076,11 +1076,11 @@ impl FeishuChannel {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Fetch and prepend quoted message content if this is a reply
|
// Fetch and prepend quoted message content if this is a reply
|
||||||
if let Some(ref pid) = parent_id {
|
if let Some(ref pid) = parent_id
|
||||||
if let Some(reply_ctx) = self.get_message_content(pid).await {
|
&& let Some(reply_ctx) = self.get_message_content(pid).await
|
||||||
|
{
|
||||||
content = format!("{}\n{}", reply_ctx, content);
|
content = format!("{}\n{}", reply_ctx, content);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
if let Some(ref m) = media {
|
if let Some(ref m) = media {
|
||||||
@ -1532,8 +1532,9 @@ fn parse_post_content(content: &str) -> String {
|
|||||||
// Fall back: try any dict child
|
// Fall back: try any dict child
|
||||||
if let Some(root_obj) = root.as_object() {
|
if let Some(root_obj) = root.as_object() {
|
||||||
for (_key, val) in root_obj {
|
for (_key, val) in root_obj {
|
||||||
if let Some(obj) = val.as_object() {
|
if let Some(obj) = val.as_object()
|
||||||
if obj.get("content").and_then(|c| c.as_array()).is_some() {
|
&& obj.get("content").and_then(|c| c.as_array()).is_some()
|
||||||
|
{
|
||||||
parse_block(val, &mut texts);
|
parse_block(val, &mut texts);
|
||||||
let result = texts.join("");
|
let result = texts.join("");
|
||||||
if !result.trim().is_empty() {
|
if !result.trim().is_empty() {
|
||||||
@ -1543,7 +1544,6 @@ fn parse_post_content(content: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
content.to_string()
|
content.to_string()
|
||||||
}
|
}
|
||||||
@ -1565,22 +1565,21 @@ fn extract_interactive_content(content: &str) -> Result<(String, Option<MediaIte
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract from card object
|
// Extract from card object
|
||||||
if let Some(card) = parsed.get("card").and_then(|c| c.as_object()) {
|
if let Some(card) = parsed.get("card").and_then(|c| c.as_object())
|
||||||
if let Some(elements) = card.get("elements").and_then(|e| e.as_array()) {
|
&& let Some(elements) = card.get("elements").and_then(|e| e.as_array())
|
||||||
|
{
|
||||||
for el in elements {
|
for el in elements {
|
||||||
extract_element_content(el, &mut texts);
|
extract_element_content(el, &mut texts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Extract from header
|
// Extract from header
|
||||||
if let Some(header) = parsed.get("header").and_then(|h| h.as_object()) {
|
if let Some(header) = parsed.get("header").and_then(|h| h.as_object())
|
||||||
if let Some(title) = header.get("title").and_then(|t| t.as_object()) {
|
&& let Some(title) = header.get("title").and_then(|t| t.as_object())
|
||||||
if let Some(text) = title.get("content").and_then(|c| c.as_str()) {
|
&& let Some(text) = title.get("content").and_then(|c| c.as_str())
|
||||||
|
{
|
||||||
texts.push(format!("title: {}\n", text));
|
texts.push(format!("title: {}\n", text));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = texts.join("").trim().to_string();
|
let result = texts.join("").trim().to_string();
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
@ -1734,8 +1733,7 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}) {
|
}) && let Some(children) = children_arr
|
||||||
if let Some(children) = children_arr
|
|
||||||
.as_object()
|
.as_object()
|
||||||
.and_then(|o| o.get("children"))
|
.and_then(|o| o.get("children"))
|
||||||
.and_then(|c| c.as_array())
|
.and_then(|c| c.as_array())
|
||||||
@ -1744,7 +1742,6 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract text from inline elements (text, link, at-mention)
|
/// Extract text from inline elements (text, link, at-mention)
|
||||||
fn extract_inline_text(el: &serde_json::Value, out: &mut String) {
|
fn extract_inline_text(el: &serde_json::Value, out: &mut String) {
|
||||||
@ -2269,138 +2266,6 @@ fn sanitize_download_file_name(file_name: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
FeishuChannel, MsgFormat, extract_file_name_from_content_disposition,
|
|
||||||
infer_download_filename, parse_post_content, sanitize_download_file_name,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn markdown_post_uses_md_tag() {
|
|
||||||
let content = "**bold**\n1. item1\n2. item2\n[link](https://open.feishu.cn)";
|
|
||||||
let post = FeishuChannel::markdown_to_post(content);
|
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&post).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(parsed["zh_cn"]["content"][0][0]["tag"], "md");
|
|
||||||
assert_eq!(parsed["zh_cn"]["content"][0][0]["text"], content);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn multiline_markdown_is_not_misclassified_as_plain_post() {
|
|
||||||
let content = "intro\n1. item1\n2. item2";
|
|
||||||
assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Post);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn headings_still_use_interactive() {
|
|
||||||
let content = "intro\n## heading";
|
|
||||||
assert_eq!(
|
|
||||||
FeishuChannel::detect_msg_format(content),
|
|
||||||
MsgFormat::Interactive
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn infer_download_filename_prefers_original_file_name() {
|
|
||||||
let content = serde_json::json!({
|
|
||||||
"file_key": "file_key_123",
|
|
||||||
"file_name": "demo-archive.zip"
|
|
||||||
});
|
|
||||||
let headers = reqwest::header::HeaderMap::new();
|
|
||||||
|
|
||||||
let filename =
|
|
||||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
|
||||||
|
|
||||||
assert_eq!(filename, "om_123_demo-archive.zip");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn infer_download_filename_uses_content_disposition_when_message_lacks_name() {
|
|
||||||
let content = serde_json::json!({
|
|
||||||
"file_key": "file_key_123"
|
|
||||||
});
|
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::CONTENT_DISPOSITION,
|
|
||||||
reqwest::header::HeaderValue::from_static("attachment; filename=meeting-notes.zip"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let filename =
|
|
||||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
|
||||||
|
|
||||||
assert_eq!(filename, "om_123_meeting-notes.zip");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn infer_download_filename_falls_back_to_bin_without_name() {
|
|
||||||
let content = serde_json::json!({
|
|
||||||
"file_key": "file_key_123"
|
|
||||||
});
|
|
||||||
let headers = reqwest::header::HeaderMap::new();
|
|
||||||
|
|
||||||
let filename =
|
|
||||||
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
|
||||||
|
|
||||||
assert_eq!(filename, "om_123_file_key.bin");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitize_download_file_name_replaces_path_separators() {
|
|
||||||
let sanitized = sanitize_download_file_name("../../demo/archive.zip");
|
|
||||||
assert_eq!(sanitized, "_.._demo_archive.zip");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_file_name_from_content_disposition_supports_filename_star() {
|
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::CONTENT_DISPOSITION,
|
|
||||||
reqwest::header::HeaderValue::from_static("attachment; filename*=UTF-8''archive.zip"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let file_name = extract_file_name_from_content_disposition(&headers);
|
|
||||||
assert_eq!(file_name.as_deref(), Some("archive.zip"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_post_content_handles_code_block_with_content_array() {
|
|
||||||
// Test parsing code_block with content array (standard Feishu format)
|
|
||||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"python","content":[{"tag":"text","text":"def hello():"},{"tag":"text","text":" print('world')"}]}]]}}}"#;
|
|
||||||
let result = parse_post_content(post_json);
|
|
||||||
assert!(result.contains("```python"));
|
|
||||||
assert!(result.contains("def hello():"));
|
|
||||||
assert!(result.contains("print('world')"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_post_content_handles_code_block_with_fallback_text() {
|
|
||||||
// Backwards compatibility: some formats might use text field directly
|
|
||||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"rust","text":"fn main() {}"}]]}}}"#;
|
|
||||||
let result = parse_post_content(post_json);
|
|
||||||
assert!(result.contains("```rust"));
|
|
||||||
assert!(result.contains("fn main() {}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_post_content_handles_code_block_without_language() {
|
|
||||||
// Test code_block without language field
|
|
||||||
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","content":[{"tag":"text","text":"plain text"}]}]]}}}"#;
|
|
||||||
let result = parse_post_content(post_json);
|
|
||||||
assert!(result.contains("```"));
|
|
||||||
assert!(result.contains("plain text"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_post_content_handles_empty_code_block() {
|
|
||||||
// Test code_block with empty content
|
|
||||||
let post_json =
|
|
||||||
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
|
|
||||||
let result = parse_post_content(post_json);
|
|
||||||
assert!(result.contains("```go"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Channel for FeishuChannel {
|
impl Channel for FeishuChannel {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
@ -2502,7 +2367,7 @@ impl Channel for FeishuChannel {
|
|||||||
let receive_id = if msg.chat_id.starts_with("oc_") {
|
let receive_id = if msg.chat_id.starts_with("oc_") {
|
||||||
&msg.chat_id
|
&msg.chat_id
|
||||||
} else {
|
} else {
|
||||||
&msg.reply_to.as_ref().unwrap_or(&msg.chat_id)
|
msg.reply_to.as_ref().unwrap_or(&msg.chat_id)
|
||||||
};
|
};
|
||||||
let receive_id_type = if msg.chat_id.starts_with("oc_") {
|
let receive_id_type = if msg.chat_id.starts_with("oc_") {
|
||||||
"chat_id"
|
"chat_id"
|
||||||
@ -2671,3 +2536,135 @@ impl Channel for FeishuChannel {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
FeishuChannel, MsgFormat, extract_file_name_from_content_disposition,
|
||||||
|
infer_download_filename, parse_post_content, sanitize_download_file_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markdown_post_uses_md_tag() {
|
||||||
|
let content = "**bold**\n1. item1\n2. item2\n[link](https://open.feishu.cn)";
|
||||||
|
let post = FeishuChannel::markdown_to_post(content);
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&post).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed["zh_cn"]["content"][0][0]["tag"], "md");
|
||||||
|
assert_eq!(parsed["zh_cn"]["content"][0][0]["text"], content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiline_markdown_is_not_misclassified_as_plain_post() {
|
||||||
|
let content = "intro\n1. item1\n2. item2";
|
||||||
|
assert_eq!(FeishuChannel::detect_msg_format(content), MsgFormat::Post);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn headings_still_use_interactive() {
|
||||||
|
let content = "intro\n## heading";
|
||||||
|
assert_eq!(
|
||||||
|
FeishuChannel::detect_msg_format(content),
|
||||||
|
MsgFormat::Interactive
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infer_download_filename_prefers_original_file_name() {
|
||||||
|
let content = serde_json::json!({
|
||||||
|
"file_key": "file_key_123",
|
||||||
|
"file_name": "demo-archive.zip"
|
||||||
|
});
|
||||||
|
let headers = reqwest::header::HeaderMap::new();
|
||||||
|
|
||||||
|
let filename =
|
||||||
|
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||||
|
|
||||||
|
assert_eq!(filename, "om_123_demo-archive.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infer_download_filename_uses_content_disposition_when_message_lacks_name() {
|
||||||
|
let content = serde_json::json!({
|
||||||
|
"file_key": "file_key_123"
|
||||||
|
});
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
reqwest::header::CONTENT_DISPOSITION,
|
||||||
|
reqwest::header::HeaderValue::from_static("attachment; filename=meeting-notes.zip"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let filename =
|
||||||
|
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||||
|
|
||||||
|
assert_eq!(filename, "om_123_meeting-notes.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn infer_download_filename_falls_back_to_bin_without_name() {
|
||||||
|
let content = serde_json::json!({
|
||||||
|
"file_key": "file_key_123"
|
||||||
|
});
|
||||||
|
let headers = reqwest::header::HeaderMap::new();
|
||||||
|
|
||||||
|
let filename =
|
||||||
|
infer_download_filename(&content, &headers, "om_123", "file_key_123", "file");
|
||||||
|
|
||||||
|
assert_eq!(filename, "om_123_file_key.bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_download_file_name_replaces_path_separators() {
|
||||||
|
let sanitized = sanitize_download_file_name("../../demo/archive.zip");
|
||||||
|
assert_eq!(sanitized, "_.._demo_archive.zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_file_name_from_content_disposition_supports_filename_star() {
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
reqwest::header::CONTENT_DISPOSITION,
|
||||||
|
reqwest::header::HeaderValue::from_static("attachment; filename*=UTF-8''archive.zip"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let file_name = extract_file_name_from_content_disposition(&headers);
|
||||||
|
assert_eq!(file_name.as_deref(), Some("archive.zip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_post_content_handles_code_block_with_content_array() {
|
||||||
|
// Test parsing code_block with content array (standard Feishu format)
|
||||||
|
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"python","content":[{"tag":"text","text":"def hello():"},{"tag":"text","text":" print('world')"}]}]]}}}"#;
|
||||||
|
let result = parse_post_content(post_json);
|
||||||
|
assert!(result.contains("```python"));
|
||||||
|
assert!(result.contains("def hello():"));
|
||||||
|
assert!(result.contains("print('world')"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_post_content_handles_code_block_with_fallback_text() {
|
||||||
|
// Backwards compatibility: some formats might use text field directly
|
||||||
|
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"rust","text":"fn main() {}"}]]}}}"#;
|
||||||
|
let result = parse_post_content(post_json);
|
||||||
|
assert!(result.contains("```rust"));
|
||||||
|
assert!(result.contains("fn main() {}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_post_content_handles_code_block_without_language() {
|
||||||
|
// Test code_block without language field
|
||||||
|
let post_json = r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","content":[{"tag":"text","text":"plain text"}]}]]}}}"#;
|
||||||
|
let result = parse_post_content(post_json);
|
||||||
|
assert!(result.contains("```"));
|
||||||
|
assert!(result.contains("plain text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_post_content_handles_empty_code_block() {
|
||||||
|
// Test code_block with empty content
|
||||||
|
let post_json =
|
||||||
|
r#"{"post":{"zh_cn":{"content":[[{"tag":"code_block","language":"go"}]]}}}"#;
|
||||||
|
let result = parse_post_content(post_json);
|
||||||
|
assert!(result.contains("```go"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -18,6 +18,12 @@ pub struct ChannelManager {
|
|||||||
websocket_channel: Arc<CliChannel>,
|
websocket_channel: Arc<CliChannel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for ChannelManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ChannelManager {
|
impl ChannelManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let websocket_channel = Arc::new(CliChannel::new());
|
let websocket_channel = Arc::new(CliChannel::new());
|
||||||
|
|||||||
@ -69,9 +69,7 @@ impl WechatChannel {
|
|||||||
let path = media.path.clone();
|
let path = media.path.clone();
|
||||||
let data = tokio::task::spawn_blocking(move || std::fs::read(&path))
|
let data = tokio::task::spawn_blocking(move || std::fs::read(&path))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ChannelError::SendError(format!("WeChat media read task failed: {}", e)))?
|
||||||
ChannelError::SendError(format!("WeChat media read task failed: {}", e))
|
|
||||||
})?
|
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
ChannelError::SendError(format!(
|
ChannelError::SendError(format!(
|
||||||
"WeChat media read failed for '{}': {}",
|
"WeChat media read failed for '{}': {}",
|
||||||
@ -419,7 +417,9 @@ mod tests {
|
|||||||
std::fs::rename(file.path(), &image_path).unwrap();
|
std::fs::rename(file.path(), &image_path).unwrap();
|
||||||
|
|
||||||
let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image");
|
let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image");
|
||||||
let content = WechatChannel::media_to_send_content(&media, None).await.unwrap();
|
let content = WechatChannel::media_to_send_content(&media, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(content, SendContent::Image { .. }));
|
assert!(matches!(content, SendContent::Image { .. }));
|
||||||
}
|
}
|
||||||
@ -432,8 +432,9 @@ 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 =
|
let content = WechatChannel::media_to_send_content(&media, Some("note".to_string()))
|
||||||
WechatChannel::media_to_send_content(&media, Some("note".to_string())).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
match content {
|
match content {
|
||||||
SendContent::File {
|
SendContent::File {
|
||||||
|
|||||||
@ -209,11 +209,11 @@ impl InitWizard {
|
|||||||
"2" => return self.modify_provider(existing).await,
|
"2" => return self.modify_provider(existing).await,
|
||||||
"3" => {
|
"3" => {
|
||||||
println!("Keeping existing providers.");
|
println!("Keeping existing providers.");
|
||||||
return Ok(existing.providers.clone());
|
Ok(existing.providers.clone())
|
||||||
}
|
}
|
||||||
"4" => {
|
"4" => {
|
||||||
println!("Skipping provider configuration.");
|
println!("Skipping provider configuration.");
|
||||||
return Ok(existing.providers.clone());
|
Ok(existing.providers.clone())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!("Invalid option, adding new provider.");
|
println!("Invalid option, adding new provider.");
|
||||||
@ -378,16 +378,16 @@ impl InitWizard {
|
|||||||
match choice.as_str() {
|
match choice.as_str() {
|
||||||
"1" => {
|
"1" => {
|
||||||
println!("Keeping existing models.");
|
println!("Keeping existing models.");
|
||||||
return Ok(existing.models.clone());
|
Ok(existing.models.clone())
|
||||||
}
|
}
|
||||||
"2" => return self.add_model(existing).await,
|
"2" => return self.add_model(existing).await,
|
||||||
"3" => {
|
"3" => {
|
||||||
println!("Skipping model configuration.");
|
println!("Skipping model configuration.");
|
||||||
return Ok(existing.models.clone());
|
Ok(existing.models.clone())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!("Invalid option, keeping existing models.");
|
println!("Invalid option, keeping existing models.");
|
||||||
return Ok(existing.models.clone());
|
Ok(existing.models.clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -505,11 +505,11 @@ impl InitWizard {
|
|||||||
"2" => return self.modify_agent(existing, providers, models).await,
|
"2" => return self.modify_agent(existing, providers, models).await,
|
||||||
"3" => {
|
"3" => {
|
||||||
println!("Keeping existing agents.");
|
println!("Keeping existing agents.");
|
||||||
return Ok(existing.agents.clone());
|
Ok(existing.agents.clone())
|
||||||
}
|
}
|
||||||
"4" => {
|
"4" => {
|
||||||
println!("Skipping agent configuration.");
|
println!("Skipping agent configuration.");
|
||||||
return Ok(existing.agents.clone());
|
Ok(existing.agents.clone())
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!("Invalid option, adding new agent.");
|
println!("Invalid option, adding new agent.");
|
||||||
|
|||||||
@ -42,12 +42,11 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let text = text.to_string();
|
let text = text.to_string();
|
||||||
if let Ok(outbound) = parse_message(&text) {
|
if let Ok(outbound) = parse_message(&text) {
|
||||||
match outbound {
|
match outbound {
|
||||||
WsOutbound::AssistantResponse { id, content, .. } => {
|
WsOutbound::AssistantResponse { id, content, .. }
|
||||||
// Skip if already fully streamed via StreamDelta
|
// Skip if already fully streamed via StreamDelta
|
||||||
if !streamed_message_ids.remove(&id) {
|
if !streamed_message_ids.remove(&id) => {
|
||||||
input.write_response(&content).await?;
|
input.write_response(&content).await?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
WsOutbound::ToolCall { tool_name, arguments, .. } => {
|
WsOutbound::ToolCall { tool_name, arguments, .. } => {
|
||||||
input.write_output(&format!("Tool call: {}\n{}\n", tool_name, format_json(&arguments))).await?;
|
input.write_output(&format!("Tool call: {}\n{}\n", tool_name, format_json(&arguments))).await?;
|
||||||
}
|
}
|
||||||
@ -235,15 +234,14 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
chat_id: current_session_id.clone(),
|
chat_id: current_session_id.clone(),
|
||||||
sender_id: None,
|
sender_id: None,
|
||||||
};
|
};
|
||||||
if let Ok(text) = serialize_inbound(&inbound) {
|
if let Ok(text) = serialize_inbound(&inbound)
|
||||||
if sender.send(Message::Text(text.into())).await.is_err() {
|
&& sender.send(Message::Text(text.into())).await.is_err() {
|
||||||
tracing::error!("Failed to send message to gateway");
|
tracing::error!("Failed to send message to gateway");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(None) => break,
|
Ok(None) => break,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Input error");
|
tracing::error!(error = %e, "Input error");
|
||||||
|
|||||||
@ -138,10 +138,10 @@ async fn handle_get_current_session(
|
|||||||
.with_message(MessageKind::Notification, &message)
|
.with_message(MessageKind::Notification, &message)
|
||||||
.with_metadata("topic_id", &topic.id)
|
.with_metadata("topic_id", &topic.id)
|
||||||
.with_metadata("title", &topic.title)
|
.with_metadata("title", &topic.title)
|
||||||
.with_metadata("message_count", &actual_message_count.to_string())
|
.with_metadata("message_count", actual_message_count.to_string())
|
||||||
.with_metadata("estimated_tokens", &total_tokens.to_string())
|
.with_metadata("estimated_tokens", total_tokens.to_string())
|
||||||
.with_metadata("system_prompt_tokens", &system_prompt_tokens.to_string())
|
.with_metadata("system_prompt_tokens", system_prompt_tokens.to_string())
|
||||||
.with_metadata("message_tokens", &message_tokens.to_string()))
|
.with_metadata("message_tokens", message_tokens.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_time_ago(timestamp_ms: i64) -> String {
|
fn format_time_ago(timestamp_ms: i64) -> String {
|
||||||
|
|||||||
@ -57,5 +57,5 @@ async fn handle_list_channels(
|
|||||||
Ok(CommandResponse::success(ctx.request_id)
|
Ok(CommandResponse::success(ctx.request_id)
|
||||||
.with_message(MessageKind::Notification, &message)
|
.with_message(MessageKind::Notification, &message)
|
||||||
.with_metadata("channels", &channels_json)
|
.with_metadata("channels", &channels_json)
|
||||||
.with_metadata("count", &channels.len().to_string()))
|
.with_metadata("count", channels.len().to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,12 +85,12 @@ async fn handle_list_sessions(
|
|||||||
));
|
));
|
||||||
|
|
||||||
// 显示描述(如果有)
|
// 显示描述(如果有)
|
||||||
if let Some(ref desc) = topic.description {
|
if let Some(ref desc) = topic.description
|
||||||
if !desc.is_empty() {
|
&& !desc.is_empty()
|
||||||
|
{
|
||||||
lines.push(format!(" {}", desc));
|
lines.push(format!(" {}", desc));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
lines.push("* = current topic".to_string());
|
lines.push("* = current topic".to_string());
|
||||||
@ -105,6 +105,6 @@ async fn handle_list_sessions(
|
|||||||
Ok(CommandResponse::success(ctx.request_id)
|
Ok(CommandResponse::success(ctx.request_id)
|
||||||
.with_message(MessageKind::Notification, &message)
|
.with_message(MessageKind::Notification, &message)
|
||||||
.with_metadata("topics", &topics_json)
|
.with_metadata("topics", &topics_json)
|
||||||
.with_metadata("count", &topics.len().to_string())
|
.with_metadata("count", topics.len().to_string())
|
||||||
.with_metadata("current_topic_id", current_topic_id))
|
.with_metadata("current_topic_id", current_topic_id))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,5 +84,5 @@ async fn handle_list_sessions_by_channel(
|
|||||||
.with_message(MessageKind::Notification, &message)
|
.with_message(MessageKind::Notification, &message)
|
||||||
.with_metadata("sessions", &sessions_json)
|
.with_metadata("sessions", &sessions_json)
|
||||||
.with_metadata("channel_name", &channel_name)
|
.with_metadata("channel_name", &channel_name)
|
||||||
.with_metadata("count", &summaries.len().to_string()))
|
.with_metadata("count", summaries.len().to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -159,5 +159,5 @@ async fn handle_list_topics(
|
|||||||
.with_message(MessageKind::Notification, &message)
|
.with_message(MessageKind::Notification, &message)
|
||||||
.with_metadata("topics", &topics_json)
|
.with_metadata("topics", &topics_json)
|
||||||
.with_metadata("session_id", &session_id)
|
.with_metadata("session_id", &session_id)
|
||||||
.with_metadata("count", &summaries.len().to_string()))
|
.with_metadata("count", summaries.len().to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -197,13 +197,13 @@ fn reconstruct_task_from_db(
|
|||||||
/// New format: "Subagent [type]: description"
|
/// New format: "Subagent [type]: description"
|
||||||
/// Legacy format: "Subagent: description" (defaults to "general")
|
/// Legacy format: "Subagent: description" (defaults to "general")
|
||||||
fn parse_subagent_title(title: &str) -> (String, String) {
|
fn parse_subagent_title(title: &str) -> (String, String) {
|
||||||
if let Some(rest) = title.strip_prefix("Subagent [") {
|
if let Some(rest) = title.strip_prefix("Subagent [")
|
||||||
if let Some(bracket_pos) = rest.find("]: ") {
|
&& let Some(bracket_pos) = rest.find("]: ")
|
||||||
|
{
|
||||||
let agent_type = rest[..bracket_pos].to_string();
|
let agent_type = rest[..bracket_pos].to_string();
|
||||||
let desc = rest[bracket_pos + 3..].to_string();
|
let desc = rest[bracket_pos + 3..].to_string();
|
||||||
return (agent_type, desc);
|
return (agent_type, desc);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let desc = title
|
let desc = title
|
||||||
.strip_prefix("Subagent: ")
|
.strip_prefix("Subagent: ")
|
||||||
.unwrap_or(title)
|
.unwrap_or(title)
|
||||||
|
|||||||
@ -60,5 +60,5 @@ async fn handle_load_topic(
|
|||||||
.with_message(MessageKind::Notification, &topic.title)
|
.with_message(MessageKind::Notification, &topic.title)
|
||||||
.with_metadata("topic_id", &topic.id)
|
.with_metadata("topic_id", &topic.id)
|
||||||
.with_metadata("title", &topic.title)
|
.with_metadata("title", &topic.title)
|
||||||
.with_metadata("message_count", &topic.message_count.to_string()))
|
.with_metadata("message_count", topic.message_count.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -95,7 +95,7 @@ async fn handle_rename_topic(
|
|||||||
return Ok(CommandResponse::success(ctx.request_id)
|
return Ok(CommandResponse::success(ctx.request_id)
|
||||||
.with_message(
|
.with_message(
|
||||||
MessageKind::Notification,
|
MessageKind::Notification,
|
||||||
&format!("✓ 话题标题未变化: {}", trimmed_title),
|
format!("✓ 话题标题未变化: {}", trimmed_title),
|
||||||
)
|
)
|
||||||
.with_metadata("topics", &topic_summaries_json)
|
.with_metadata("topics", &topic_summaries_json)
|
||||||
.with_metadata("topic_id", &topic_id)
|
.with_metadata("topic_id", &topic_id)
|
||||||
|
|||||||
@ -72,12 +72,13 @@ pub async fn save_session_to_file(
|
|||||||
let output_path = resolve_filepath(filepath, &record);
|
let output_path = resolve_filepath(filepath, &record);
|
||||||
|
|
||||||
// 创建父目录
|
// 创建父目录
|
||||||
if let Some(parent) = output_path.parent() {
|
if let Some(parent) = output_path.parent()
|
||||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
&& !parent.as_os_str().is_empty()
|
||||||
|
&& !parent.exists()
|
||||||
|
{
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||||
@ -192,7 +193,7 @@ async fn handle_save_session(
|
|||||||
filepath,
|
filepath,
|
||||||
include_all,
|
include_all,
|
||||||
include_subagents,
|
include_subagents,
|
||||||
&*handler.store,
|
&handler.store,
|
||||||
Some(handler.task_repository.as_ref()),
|
Some(handler.task_repository.as_ref()),
|
||||||
&*handler.system_prompt_provider,
|
&*handler.system_prompt_provider,
|
||||||
)
|
)
|
||||||
@ -213,16 +214,16 @@ async fn handle_save_session(
|
|||||||
MessageKind::Notification,
|
MessageKind::Notification,
|
||||||
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
||||||
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
||||||
&format!(
|
format!(
|
||||||
"Session saved to: {}",
|
"Session saved to: {}",
|
||||||
output_path.display().to_string().replace('\\', "/")
|
output_path.display().to_string().replace('\\', "/")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.with_metadata(
|
.with_metadata(
|
||||||
"filepath",
|
"filepath",
|
||||||
&output_path.display().to_string().replace('\\', "/"),
|
output_path.display().to_string().replace('\\', "/"),
|
||||||
)
|
)
|
||||||
.with_metadata("message_count", &message_count.to_string()))
|
.with_metadata("message_count", message_count.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 子智能体任务数据
|
/// 子智能体任务数据
|
||||||
@ -391,8 +392,9 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 工具调用
|
// 工具调用
|
||||||
if let Some(ref calls) = msg.tool_calls {
|
if let Some(ref calls) = msg.tool_calls
|
||||||
if !calls.is_empty() {
|
&& !calls.is_empty()
|
||||||
|
{
|
||||||
output.push_str("**Tool Calls:**\n\n");
|
output.push_str("**Tool Calls:**\n\n");
|
||||||
for call in calls {
|
for call in calls {
|
||||||
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
|
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
|
||||||
@ -406,7 +408,6 @@ pub fn generate_subagent_tasks_markdown(subagent_data: &[SubagentTaskData]) -> S
|
|||||||
}
|
}
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
output.push_str("---\n\n");
|
output.push_str("---\n\n");
|
||||||
}
|
}
|
||||||
@ -560,8 +561,9 @@ pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> Strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls
|
// Tool calls
|
||||||
if let Some(ref calls) = msg.tool_calls {
|
if let Some(ref calls) = msg.tool_calls
|
||||||
if !calls.is_empty() {
|
&& !calls.is_empty()
|
||||||
|
{
|
||||||
output.push_str("### Tool Calls\n\n");
|
output.push_str("### Tool Calls\n\n");
|
||||||
for call in calls {
|
for call in calls {
|
||||||
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
|
output.push_str(&format!("- **{}** (`{}`)\n", call.name, call.id));
|
||||||
@ -575,7 +577,6 @@ pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> Strin
|
|||||||
}
|
}
|
||||||
output.push('\n');
|
output.push('\n');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Media refs
|
// Media refs
|
||||||
if !msg.media_refs.is_empty() {
|
if !msg.media_refs.is_empty() {
|
||||||
@ -621,16 +622,7 @@ pub fn resolve_filepath(filepath: Option<String>, record: &SessionRecord) -> Pat
|
|||||||
// 生成安全标题(替换特殊字符)
|
// 生成安全标题(替换特殊字符)
|
||||||
let safe_title = record
|
let safe_title = record
|
||||||
.title
|
.title
|
||||||
.replace(' ', "_")
|
.replace([' ', '/', '\\', ':', '<', '>', '|', '?', '*', '"'], "_");
|
||||||
.replace('/', "_")
|
|
||||||
.replace('\\', "_")
|
|
||||||
.replace(':', "_")
|
|
||||||
.replace('<', "_")
|
|
||||||
.replace('>', "_")
|
|
||||||
.replace('|', "_")
|
|
||||||
.replace('?', "_")
|
|
||||||
.replace('*', "_")
|
|
||||||
.replace('"', "_");
|
|
||||||
|
|
||||||
// 使用标题或 session_id 作为文件名
|
// 使用标题或 session_id 作为文件名
|
||||||
let base_name = if safe_title.is_empty() {
|
let base_name = if safe_title.is_empty() {
|
||||||
@ -716,7 +708,7 @@ impl InChatCommandHandler for SaveSessionInChatHandler {
|
|||||||
filepath,
|
filepath,
|
||||||
include_all,
|
include_all,
|
||||||
include_subagents,
|
include_subagents,
|
||||||
&*self.store,
|
&self.store,
|
||||||
Some(self.task_repository.as_ref()),
|
Some(self.task_repository.as_ref()),
|
||||||
&*self.system_prompt_provider,
|
&*self.system_prompt_provider,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -54,12 +54,13 @@ pub async fn save_topic_to_file(
|
|||||||
let output_path = resolve_topic_filepath(filepath, &topic);
|
let output_path = resolve_topic_filepath(filepath, &topic);
|
||||||
|
|
||||||
// 创建父目录
|
// 创建父目录
|
||||||
if let Some(parent) = output_path.parent() {
|
if let Some(parent) = output_path.parent()
|
||||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
&& !parent.as_os_str().is_empty()
|
||||||
|
&& !parent.exists()
|
||||||
|
{
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
.map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
std::fs::write(&output_path, markdown).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||||
@ -138,16 +139,7 @@ fn resolve_topic_filepath(filepath: Option<String>, topic: &TopicRecord) -> Path
|
|||||||
None => {
|
None => {
|
||||||
let safe_title = topic
|
let safe_title = topic
|
||||||
.title
|
.title
|
||||||
.replace(' ', "_")
|
.replace([' ', '/', '\\', ':', '<', '>', '|', '?', '*', '"'], "_");
|
||||||
.replace('/', "_")
|
|
||||||
.replace('\\', "_")
|
|
||||||
.replace(':', "_")
|
|
||||||
.replace('<', "_")
|
|
||||||
.replace('>', "_")
|
|
||||||
.replace('|', "_")
|
|
||||||
.replace('?', "_")
|
|
||||||
.replace('*', "_")
|
|
||||||
.replace('"', "_");
|
|
||||||
|
|
||||||
let base_name = if safe_title.is_empty() {
|
let base_name = if safe_title.is_empty() {
|
||||||
format!("topic_{}", &topic.id[..8.min(topic.id.len())])
|
format!("topic_{}", &topic.id[..8.min(topic.id.len())])
|
||||||
@ -267,7 +259,7 @@ async fn handle_save_topic(
|
|||||||
topic_id,
|
topic_id,
|
||||||
filepath,
|
filepath,
|
||||||
include_subagents,
|
include_subagents,
|
||||||
&*handler.store,
|
&handler.store,
|
||||||
Some(handler.task_repository.as_ref()),
|
Some(handler.task_repository.as_ref()),
|
||||||
&*handler.system_prompt_provider,
|
&*handler.system_prompt_provider,
|
||||||
&messages,
|
&messages,
|
||||||
@ -282,14 +274,14 @@ async fn handle_save_topic(
|
|||||||
MessageKind::Notification,
|
MessageKind::Notification,
|
||||||
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
// 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉,
|
||||||
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
// 统一转换为正斜杠以保证显示完整(跨平台兼容)
|
||||||
&format!(
|
format!(
|
||||||
"Topic saved to: {}",
|
"Topic saved to: {}",
|
||||||
output_path.display().to_string().replace('\\', "/")
|
output_path.display().to_string().replace('\\', "/")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.with_metadata(
|
.with_metadata(
|
||||||
"filepath",
|
"filepath",
|
||||||
&output_path.display().to_string().replace('\\', "/"),
|
output_path.display().to_string().replace('\\', "/"),
|
||||||
)
|
)
|
||||||
.with_metadata("message_count", &message_count.to_string()))
|
.with_metadata("message_count", message_count.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,14 +94,14 @@ async fn handle_create_session(
|
|||||||
.ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?;
|
.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 {
|
&& 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
|
session_guard
|
||||||
.switch_topic(chat_id, &topic.id)
|
.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()))?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Query the full topic list so the frontend sidebar can update
|
// Query the full topic list so the frontend sidebar can update
|
||||||
let topics = handler
|
let topics = handler
|
||||||
@ -119,7 +119,7 @@ async fn handle_create_session(
|
|||||||
.with_metadata("topics", &topics_json)
|
.with_metadata("topics", &topics_json)
|
||||||
.with_metadata("topic_id", &topic.id)
|
.with_metadata("topic_id", &topic.id)
|
||||||
.with_metadata("session_id", &topic.session_id)
|
.with_metadata("session_id", &topic.session_id)
|
||||||
.with_metadata("message_count", &topic.message_count.to_string()))
|
.with_metadata("message_count", topic.message_count.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -108,10 +108,7 @@ impl CommandHandler for StopExecutionCommandHandler {
|
|||||||
|
|
||||||
if cancelled || cancelled_subagents > 0 {
|
if cancelled || cancelled_subagents > 0 {
|
||||||
let msg = if cancelled && cancelled_subagents > 0 {
|
let msg = if cancelled && cancelled_subagents > 0 {
|
||||||
format!(
|
format!("正在停止当前任务及 {} 个后台子代理...", cancelled_subagents)
|
||||||
"正在停止当前任务及 {} 个后台子代理...",
|
|
||||||
cancelled_subagents
|
|
||||||
)
|
|
||||||
} else if cancelled {
|
} else if cancelled {
|
||||||
"正在停止当前任务...".to_string()
|
"正在停止当前任务...".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -103,14 +103,14 @@ async fn handle_switch_topic(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 如果有 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 {
|
&& 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
|
session_guard
|
||||||
.switch_topic(chat_id, &target_topic_id)
|
.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
|
let msg_count = handler
|
||||||
@ -127,5 +127,5 @@ async fn handle_switch_topic(
|
|||||||
.with_message(MessageKind::Notification, &message)
|
.with_message(MessageKind::Notification, &message)
|
||||||
.with_metadata("topic_id", &topic.id)
|
.with_metadata("topic_id", &topic.id)
|
||||||
.with_metadata("title", &topic.title)
|
.with_metadata("title", &topic.title)
|
||||||
.with_metadata("message_count", &msg_count.to_string()))
|
.with_metadata("message_count", msg_count.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -128,7 +128,7 @@ impl Default for CompactionConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 可观测性配置(日志格式、metrics 开关等)
|
/// 可观测性配置(日志格式、metrics 开关等)
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
pub struct ObservabilityConfig {
|
pub struct ObservabilityConfig {
|
||||||
/// 日志输出格式:text(默认)或 json。
|
/// 日志输出格式:text(默认)或 json。
|
||||||
/// json 格式便于接入 ELK/Loki 等日志聚合系统。
|
/// json 格式便于接入 ELK/Loki 等日志聚合系统。
|
||||||
@ -136,14 +136,6 @@ pub struct ObservabilityConfig {
|
|||||||
pub log_format: LogFormat,
|
pub log_format: LogFormat,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ObservabilityConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
log_format: LogFormat::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 日志输出格式
|
/// 日志输出格式
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@ -2305,25 +2297,33 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_scheduler_schedule_validation_rejects_invalid_values() {
|
fn test_scheduler_schedule_validation_rejects_invalid_values() {
|
||||||
assert!(SchedulerSchedule::Delay { seconds: 0 }
|
assert!(
|
||||||
|
SchedulerSchedule::Delay { seconds: 0 }
|
||||||
.validate("delay.job")
|
.validate("delay.job")
|
||||||
.is_err());
|
.is_err()
|
||||||
assert!(SchedulerSchedule::Interval {
|
);
|
||||||
|
assert!(
|
||||||
|
SchedulerSchedule::Interval {
|
||||||
seconds: 0,
|
seconds: 0,
|
||||||
startup_delay_secs: 0,
|
startup_delay_secs: 0,
|
||||||
}
|
}
|
||||||
.validate("interval.job")
|
.validate("interval.job")
|
||||||
.is_err());
|
.is_err()
|
||||||
assert!(SchedulerSchedule::At {
|
);
|
||||||
|
assert!(
|
||||||
|
SchedulerSchedule::At {
|
||||||
timestamp: "bad timestamp".to_string(),
|
timestamp: "bad timestamp".to_string(),
|
||||||
}
|
}
|
||||||
.validate("at.job")
|
.validate("at.job")
|
||||||
.is_err());
|
.is_err()
|
||||||
assert!(SchedulerSchedule::Cron {
|
);
|
||||||
|
assert!(
|
||||||
|
SchedulerSchedule::Cron {
|
||||||
expression: "bad cron".to_string(),
|
expression: "bad cron".to_string(),
|
||||||
}
|
}
|
||||||
.validate("cron.job")
|
.validate("cron.job")
|
||||||
.is_err());
|
.is_err()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -63,14 +63,14 @@ impl CapabilityPolicy {
|
|||||||
|
|
||||||
/// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。
|
/// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。
|
||||||
pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> {
|
pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> {
|
||||||
if let Some(list) = &self.allowed_subagents {
|
if let Some(list) = &self.allowed_subagents
|
||||||
if !list.iter().any(|s| s == name) {
|
&& !list.iter().any(|s| s == name)
|
||||||
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"subagent '{}' is not in the allowed_subagents whitelist",
|
"subagent '{}' is not in the allowed_subagents whitelist",
|
||||||
name
|
name
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if self.denied_subagents.iter().any(|s| s == name) {
|
if self.denied_subagents.iter().any(|s| s == name) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"subagent '{}' is in the denied_subagents blacklist",
|
"subagent '{}' is in the denied_subagents blacklist",
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
use crate::config::ExpertsConfig;
|
use crate::config::ExpertsConfig;
|
||||||
use crate::domain::CapabilityPolicy;
|
use crate::domain::CapabilityPolicy;
|
||||||
use crate::platform::{atomic_rename, home_dir as platform_home_dir};
|
use crate::platform::{atomic_rename, home_dir as platform_home_dir};
|
||||||
|
use parking_lot::RwLock;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use parking_lot::RwLock;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
@ -294,18 +294,13 @@ 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
|
let config = self.config.read().clone();
|
||||||
.config
|
|
||||||
.read()
|
|
||||||
.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
|
let mut guard = self.catalog.write();
|
||||||
.catalog
|
|
||||||
.write();
|
|
||||||
*guard = catalog.clone();
|
*guard = catalog.clone();
|
||||||
Ok(catalog)
|
Ok(catalog)
|
||||||
}
|
}
|
||||||
@ -323,18 +318,12 @@ impl ExpertRuntime {
|
|||||||
|
|
||||||
/// List enabled experts (disabled ones are filtered out).
|
/// List enabled experts (disabled ones are filtered out).
|
||||||
pub fn list_experts(&self) -> Vec<Expert> {
|
pub fn list_experts(&self) -> Vec<Expert> {
|
||||||
self.catalog
|
self.catalog.read().experts.clone()
|
||||||
.read()
|
|
||||||
.experts
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
let config = self.config.read().clone();
|
||||||
.config
|
|
||||||
.read()
|
|
||||||
.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);
|
||||||
|
|
||||||
@ -361,10 +350,7 @@ impl ExpertRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_expert(&self, name: &str) -> Option<Expert> {
|
pub fn get_expert(&self, name: &str) -> Option<Expert> {
|
||||||
self.catalog
|
self.catalog.read().find_expert(name).cloned()
|
||||||
.read()
|
|
||||||
.find_expert(name)
|
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_expert(
|
pub fn create_expert(
|
||||||
@ -474,10 +460,7 @@ 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
|
let config = self.config.read().clone();
|
||||||
.config
|
|
||||||
.read()
|
|
||||||
.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())
|
||||||
}
|
}
|
||||||
@ -509,9 +492,7 @@ impl ExpertRuntime {
|
|||||||
|
|
||||||
// update in-memory disable_state
|
// update in-memory disable_state
|
||||||
{
|
{
|
||||||
let mut state = self
|
let mut state = self.disable_state.write();
|
||||||
.disable_state
|
|
||||||
.write();
|
|
||||||
match scope {
|
match scope {
|
||||||
ExpertScope::User => {
|
ExpertScope::User => {
|
||||||
if enabled {
|
if enabled {
|
||||||
@ -533,9 +514,7 @@ impl ExpertRuntime {
|
|||||||
// refresh catalog so list_experts / get_expert reflect the change
|
// refresh catalog so list_experts / get_expert reflect the change
|
||||||
let _ = self.reload()?;
|
let _ = self.reload()?;
|
||||||
|
|
||||||
let state = self
|
let state = self.disable_state.read();
|
||||||
.disable_state
|
|
||||||
.read();
|
|
||||||
let disabled_in_scopes = state.disabled_scopes_for(name);
|
let disabled_in_scopes = state.disabled_scopes_for(name);
|
||||||
|
|
||||||
Ok(ExpertAvailabilityChange {
|
Ok(ExpertAvailabilityChange {
|
||||||
@ -558,9 +537,7 @@ impl ExpertRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut sessions = self
|
let mut sessions = self.session_experts.write();
|
||||||
.session_experts
|
|
||||||
.write();
|
|
||||||
sessions.insert(session_id.to_string(), expert_name.to_string());
|
sessions.insert(session_id.to_string(), expert_name.to_string());
|
||||||
}
|
}
|
||||||
persist_session_experts(&self.cwd, |state| {
|
persist_session_experts(&self.cwd, |state| {
|
||||||
@ -573,9 +550,7 @@ impl ExpertRuntime {
|
|||||||
/// Clear the selected expert for a session.
|
/// Clear the selected expert for a session.
|
||||||
pub fn clear_expert(&self, session_id: &str) -> Result<(), String> {
|
pub fn clear_expert(&self, session_id: &str) -> Result<(), String> {
|
||||||
{
|
{
|
||||||
let mut sessions = self
|
let mut sessions = self.session_experts.write();
|
||||||
.session_experts
|
|
||||||
.write();
|
|
||||||
sessions.remove(session_id);
|
sessions.remove(session_id);
|
||||||
}
|
}
|
||||||
persist_session_experts(&self.cwd, |state| {
|
persist_session_experts(&self.cwd, |state| {
|
||||||
@ -586,16 +561,12 @@ impl ExpertRuntime {
|
|||||||
/// Returns the expert selected for a session, or None if none selected / disabled / not found.
|
/// Returns the expert selected for a session, or None if none selected / disabled / not found.
|
||||||
pub fn selected_expert_for(&self, session_id: &str) -> Option<Expert> {
|
pub fn selected_expert_for(&self, session_id: &str) -> Option<Expert> {
|
||||||
let name = {
|
let name = {
|
||||||
let sessions = self
|
let sessions = self.session_experts.read();
|
||||||
.session_experts
|
|
||||||
.read();
|
|
||||||
sessions.get(session_id).cloned()
|
sessions.get(session_id).cloned()
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
// Filter out disabled experts.
|
// Filter out disabled experts.
|
||||||
let state = self
|
let state = self.disable_state.read();
|
||||||
.disable_state
|
|
||||||
.read();
|
|
||||||
if state.is_disabled(&name) {
|
if state.is_disabled(&name) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,9 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::agent::context_compressor::ContextCompressor;
|
use crate::agent::context_compressor::ContextCompressor;
|
||||||
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider};
|
use crate::agent::{
|
||||||
|
AgentError, AgentLoop, AgentRuntimeConfig, CompositeSystemPromptProvider, SystemPromptProvider,
|
||||||
|
};
|
||||||
use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver};
|
use crate::config::{CompactionConfig, LLMProviderConfig, ModelResolver};
|
||||||
use crate::domain::CapabilityPolicy;
|
use crate::domain::CapabilityPolicy;
|
||||||
use crate::experts::ExpertPromptProvider;
|
use crate::experts::ExpertPromptProvider;
|
||||||
@ -14,10 +16,10 @@ use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
|||||||
use crate::observability::Observer;
|
use crate::observability::Observer;
|
||||||
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
||||||
use crate::storage::PromptInjectionRepository;
|
use crate::storage::PromptInjectionRepository;
|
||||||
use crate::storage::persistent_session_id;
|
|
||||||
use crate::storage::SessionStore;
|
use crate::storage::SessionStore;
|
||||||
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
|
use crate::storage::persistent_session_id;
|
||||||
use crate::tools::task::SubagentResult;
|
use crate::tools::task::SubagentResult;
|
||||||
|
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
|
||||||
use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator};
|
use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator};
|
||||||
|
|
||||||
/// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。
|
/// 构建与 Agent 实际使用的完全一致的组合系统提示词 Provider。
|
||||||
@ -133,7 +135,10 @@ impl AgentFactory {
|
|||||||
/// 构造 ContextCompressor(参数内聚到 ContextCompressor,CompactionConfig 注入)。
|
/// 构造 ContextCompressor(参数内聚到 ContextCompressor,CompactionConfig 注入)。
|
||||||
/// AgentLoop(in-loop 压缩)和 Session(sync 兜底压缩)共用此方法,
|
/// AgentLoop(in-loop 压缩)和 Session(sync 兜底压缩)共用此方法,
|
||||||
/// 确保两条压缩路径使用同一套用户配置的压缩参数。
|
/// 确保两条压缩路径使用同一套用户配置的压缩参数。
|
||||||
pub(crate) fn build_compressor(&self, runtime_config: &AgentRuntimeConfig) -> ContextCompressor {
|
pub(crate) fn build_compressor(
|
||||||
|
&self,
|
||||||
|
runtime_config: &AgentRuntimeConfig,
|
||||||
|
) -> ContextCompressor {
|
||||||
ContextCompressor::with_compaction_config(
|
ContextCompressor::with_compaction_config(
|
||||||
runtime_config.context_window_tokens,
|
runtime_config.context_window_tokens,
|
||||||
runtime_config.context_summary_char_budget,
|
runtime_config.context_summary_char_budget,
|
||||||
@ -201,14 +206,17 @@ impl AgentFactory {
|
|||||||
|
|
||||||
// 物化:命中 session 级选择且话题无固化值时,将解析后的具体
|
// 物化:命中 session 级选择且话题无固化值时,将解析后的具体
|
||||||
// (provider, model) 写入 topics 行(持久化 + 内存缓存)
|
// (provider, model) 写入 topics 行(持久化 + 内存缓存)
|
||||||
if !from_topic {
|
if !from_topic && let Some(tid) = request.topic_id.as_deref() {
|
||||||
if let Some(tid) = request.topic_id.as_deref() {
|
|
||||||
let provider = resolved.name.clone();
|
let provider = resolved.name.clone();
|
||||||
let model = resolved.model_id.clone();
|
let model = resolved.model_id.clone();
|
||||||
self.topic_model_selections
|
self.topic_model_selections.set(
|
||||||
.set(tid, Some(provider.clone()), Some(model.clone()));
|
tid,
|
||||||
|
Some(provider.clone()),
|
||||||
|
Some(model.clone()),
|
||||||
|
);
|
||||||
if let Err(err) =
|
if let Err(err) =
|
||||||
self.store.update_topic_model(tid, Some(&provider), Some(&model))
|
self.store
|
||||||
|
.update_topic_model(tid, Some(&provider), Some(&model))
|
||||||
{
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %err,
|
error = %err,
|
||||||
@ -217,7 +225,6 @@ impl AgentFactory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
instance_id = self.instance_id,
|
instance_id = self.instance_id,
|
||||||
@ -289,7 +296,7 @@ impl AgentFactory {
|
|||||||
// 供 wait_for_subagents 工具传递给 coordinator.wait() 的 select!。
|
// 供 wait_for_subagents 工具传递给 coordinator.wait() 的 select!。
|
||||||
// watch::Receiver::clone() 创建共享同一 sender 的新 receiver,
|
// watch::Receiver::clone() 创建共享同一 sender 的新 receiver,
|
||||||
// 各 receiver 的 has_changed()/changed() 状态独立,互不影响。
|
// 各 receiver 的 has_changed()/changed() 状态独立,互不影响。
|
||||||
let cancel_rx_for_context = request.cancel_token.as_ref().map(|rx| rx.clone());
|
let cancel_rx_for_context = request.cancel_token.clone();
|
||||||
|
|
||||||
let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
|
let runtime_config = AgentRuntimeConfig::from(effective_provider_config.clone());
|
||||||
let compressor = Arc::new(self.build_compressor(&runtime_config));
|
let compressor = Arc::new(self.build_compressor(&runtime_config));
|
||||||
|
|||||||
@ -42,8 +42,9 @@ impl AgentPromptProvider {
|
|||||||
|
|
||||||
/// 记录注入事件
|
/// 记录注入事件
|
||||||
fn record_injection(&self, context: &SystemPromptContext) {
|
fn record_injection(&self, context: &SystemPromptContext) {
|
||||||
if let Some(session_id) = &context.session_id {
|
if let Some(session_id) = &context.session_id
|
||||||
if let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id) {
|
&& let Err(e) = self.repository.mark_agent_prompt_reinjected(session_id)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
session_id = ?session_id,
|
session_id = ?session_id,
|
||||||
error = %e,
|
error = %e,
|
||||||
@ -52,7 +53,6 @@ impl AgentPromptProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl SystemPromptProvider for AgentPromptProvider {
|
impl SystemPromptProvider for AgentPromptProvider {
|
||||||
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
|
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
|
||||||
|
|||||||
@ -6,11 +6,11 @@
|
|||||||
//! - token 通过 `Authorization: Bearer <token>`(HTTP)或 `?token=<token>`(WS)传递。
|
//! - token 通过 `Authorization: Bearer <token>`(HTTP)或 `?token=<token>`(WS)传递。
|
||||||
//! - 校验使用常量时间比较,避免计时侧信道。
|
//! - 校验使用常量时间比较,避免计时侧信道。
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
@ -88,11 +88,7 @@ pub fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
|||||||
/// 仅在 `requires_auth` 为 true 时挂载。
|
/// 仅在 `requires_auth` 为 true 时挂载。
|
||||||
/// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。
|
/// `/health`、`/ws`、静态资源放行;`/ws` 的 token 校验在 ws_handler 内完成。
|
||||||
/// `/metrics` 包含运行时指标(provider/model/耗时/token 用量),远程部署时需保护。
|
/// `/metrics` 包含运行时指标(provider/model/耗时/token 用量),远程部署时需保护。
|
||||||
pub async fn require_bearer_auth(
|
pub async fn require_bearer_auth(headers: HeaderMap, request: Request, next: Next) -> Response {
|
||||||
headers: HeaderMap,
|
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
let path = request.uri().path();
|
let path = request.uri().path();
|
||||||
|
|
||||||
// /api/* 和 /metrics 需要认证;其余放行
|
// /api/* 和 /metrics 需要认证;其余放行
|
||||||
|
|||||||
@ -155,7 +155,7 @@ impl AgentExecutionService {
|
|||||||
// 直接比较 current_topic(chat_id) 与 original_topic_id
|
// 直接比较 current_topic(chat_id) 与 original_topic_id
|
||||||
// 这比"检查内存历史最新消息"更可靠,且天然处理"切走又切回"的 case
|
// 这比"检查内存历史最新消息"更可靠,且天然处理"切走又切回"的 case
|
||||||
let is_current_turn = match request.original_topic_id.as_deref() {
|
let is_current_turn = match request.original_topic_id.as_deref() {
|
||||||
Some(orig_tid) => session.current_topic(request.chat_id).as_deref() == Some(orig_tid),
|
Some(orig_tid) => session.current_topic(request.chat_id) == Some(orig_tid),
|
||||||
None => true, // 无 topic 时总是视为当前回合
|
None => true, // 无 topic 时总是视为当前回合
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -419,7 +419,11 @@ impl AgentExecutionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let result = agent
|
let result = agent
|
||||||
.process(history, Some(&system_prompt_context), Some(&compaction_sink))
|
.process(
|
||||||
|
history,
|
||||||
|
Some(&system_prompt_context),
|
||||||
|
Some(&compaction_sink),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let mut metadata = HashMap::new();
|
let mut metadata = HashMap::new();
|
||||||
// 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID,使 todo 点击跳转能匹配
|
// 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID,使 todo 点击跳转能匹配
|
||||||
@ -605,7 +609,11 @@ impl AgentExecutionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let result = agent
|
let result = agent
|
||||||
.process(history, Some(&system_prompt_context), Some(&compaction_sink))
|
.process(
|
||||||
|
history,
|
||||||
|
Some(&system_prompt_context),
|
||||||
|
Some(&compaction_sink),
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let outbound_messages = self
|
let outbound_messages = self
|
||||||
|
|||||||
@ -65,20 +65,20 @@ fn mask_config(config: &Config) -> Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for channel in masked.channels.values_mut() {
|
for channel in masked.channels.values_mut() {
|
||||||
if let Some(feishu) = channel.as_feishu_mut() {
|
if let Some(feishu) = channel.as_feishu_mut()
|
||||||
if !feishu.app_secret.is_empty() {
|
&& !feishu.app_secret.is_empty()
|
||||||
|
{
|
||||||
let visible: String = feishu.app_secret.chars().take(4).collect();
|
let visible: String = feishu.app_secret.chars().take(4).collect();
|
||||||
feishu.app_secret = format!("{}{}", visible, API_KEY_MASK);
|
feishu.app_secret = format!("{}{}", visible, API_KEY_MASK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// 掩码网关认证 token(避免通过 /api/config 泄露)
|
// 掩码网关认证 token(避免通过 /api/config 泄露)
|
||||||
if let Some(ref token) = masked.gateway.auth_token {
|
if let Some(ref token) = masked.gateway.auth_token
|
||||||
if !token.is_empty() {
|
&& !token.is_empty()
|
||||||
|
{
|
||||||
let visible: String = token.chars().take(4).collect();
|
let visible: String = token.chars().take(4).collect();
|
||||||
masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK));
|
masked.gateway.auth_token = Some(format!("{}{}", visible, API_KEY_MASK));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
masked
|
masked
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,29 +116,27 @@ pub async fn save_config(
|
|||||||
{
|
{
|
||||||
let cfg = state.config.read().await;
|
let cfg = state.config.read().await;
|
||||||
for (name, provider) in new_config.providers.iter_mut() {
|
for (name, provider) in new_config.providers.iter_mut() {
|
||||||
if is_masked_key(&provider.api_key) {
|
if is_masked_key(&provider.api_key)
|
||||||
if let Some(original) = cfg.providers.get(name) {
|
&& let Some(original) = cfg.providers.get(name)
|
||||||
|
{
|
||||||
provider.api_key = original.api_key.clone();
|
provider.api_key = original.api_key.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (name, channel) in new_config.channels.iter_mut() {
|
for (name, channel) in new_config.channels.iter_mut() {
|
||||||
if let Some(feishu) = channel.as_feishu_mut() {
|
if let Some(feishu) = channel.as_feishu_mut()
|
||||||
if is_masked_key(&feishu.app_secret) {
|
&& is_masked_key(&feishu.app_secret)
|
||||||
if let Some(original_channel) = cfg.channels.get(name) {
|
&& let Some(original_channel) = cfg.channels.get(name)
|
||||||
if let Some(original_feishu) = original_channel.as_feishu() {
|
&& let Some(original_feishu) = original_channel.as_feishu()
|
||||||
|
{
|
||||||
feishu.app_secret = original_feishu.app_secret.clone();
|
feishu.app_secret = original_feishu.app_secret.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 保留原始 auth_token(若提交的是掩码值)
|
// 保留原始 auth_token(若提交的是掩码值)
|
||||||
if let Some(ref submitted) = new_config.gateway.auth_token {
|
if let Some(ref submitted) = new_config.gateway.auth_token
|
||||||
if is_masked_key(submitted) {
|
&& is_masked_key(submitted)
|
||||||
|
{
|
||||||
new_config.gateway.auth_token = cfg.gateway.auth_token.clone();
|
new_config.gateway.auth_token = cfg.gateway.auth_token.clone();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} // read lock released here
|
} // read lock released here
|
||||||
|
|
||||||
// Validate timezone
|
// Validate timezone
|
||||||
@ -243,9 +241,7 @@ pub async fn list_executions(State(state): State<Arc<GatewayState>>) -> Json<Exe
|
|||||||
/// GET /metrics — Prometheus metrics 端点
|
/// GET /metrics — Prometheus metrics 端点
|
||||||
///
|
///
|
||||||
/// 返回 Prometheus 格式的 metrics 文本。若 recorder 未安装则返回 503。
|
/// 返回 Prometheus 格式的 metrics 文本。若 recorder 未安装则返回 503。
|
||||||
pub async fn metrics_handler(
|
pub async fn metrics_handler(State(state): State<Arc<GatewayState>>) -> (StatusCode, String) {
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
) -> (StatusCode, String) {
|
|
||||||
match &state.prometheus_handle {
|
match &state.prometheus_handle {
|
||||||
Some(handle) => (StatusCode::OK, handle.render()),
|
Some(handle) => (StatusCode::OK, handle.render()),
|
||||||
None => (
|
None => (
|
||||||
@ -1160,8 +1156,9 @@ pub async fn session_select_model(
|
|||||||
// 校验:provider/model 名必须在 config 的 providers/models 表中存在
|
// 校验:provider/model 名必须在 config 的 providers/models 表中存在
|
||||||
// (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误)
|
// (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误)
|
||||||
let config = state.config.read().await;
|
let config = state.config.read().await;
|
||||||
if let Some(name) = provider.as_ref() {
|
if let Some(name) = provider.as_ref()
|
||||||
if !config.providers.contains_key(name) {
|
&& !config.providers.contains_key(name)
|
||||||
|
{
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(SelectModelResponse {
|
Json(SelectModelResponse {
|
||||||
@ -1170,9 +1167,9 @@ pub async fn session_select_model(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
if let Some(name) = model.as_ref()
|
||||||
if let Some(name) = model.as_ref() {
|
&& !config.models.contains_key(name)
|
||||||
if !config.models.contains_key(name) {
|
{
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(SelectModelResponse {
|
Json(SelectModelResponse {
|
||||||
@ -1181,7 +1178,6 @@ pub async fn session_select_model(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
drop(config);
|
drop(config);
|
||||||
|
|
||||||
state.model_selections.set(&req.session_id, provider, model);
|
state.model_selections.set(&req.session_id, provider, model);
|
||||||
@ -1274,8 +1270,9 @@ pub async fn topic_select_model(
|
|||||||
|
|
||||||
// 校验:provider/model 名必须在 config 的 providers/models 表中存在
|
// 校验:provider/model 名必须在 config 的 providers/models 表中存在
|
||||||
let config = state.config.read().await;
|
let config = state.config.read().await;
|
||||||
if let Some(name) = provider.as_ref() {
|
if let Some(name) = provider.as_ref()
|
||||||
if !config.providers.contains_key(name) {
|
&& !config.providers.contains_key(name)
|
||||||
|
{
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(SelectModelResponse {
|
Json(SelectModelResponse {
|
||||||
@ -1284,9 +1281,9 @@ pub async fn topic_select_model(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
if let Some(name) = model.as_ref()
|
||||||
if let Some(name) = model.as_ref() {
|
&& !config.models.contains_key(name)
|
||||||
if !config.models.contains_key(name) {
|
{
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(SelectModelResponse {
|
Json(SelectModelResponse {
|
||||||
@ -1295,7 +1292,6 @@ pub async fn topic_select_model(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
drop(config);
|
drop(config);
|
||||||
|
|
||||||
let is_clear = provider.is_none() && model.is_none();
|
let is_clear = provider.is_none() && model.is_none();
|
||||||
@ -1320,7 +1316,9 @@ pub async fn topic_select_model(
|
|||||||
if is_clear {
|
if is_clear {
|
||||||
state.model_selections.set(&topic_session_id, None, None);
|
state.model_selections.set(&topic_session_id, None, None);
|
||||||
} else {
|
} else {
|
||||||
state.model_selections.set(&topic_session_id, provider, model);
|
state
|
||||||
|
.model_selections
|
||||||
|
.set(&topic_session_id, provider, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
(
|
(
|
||||||
|
|||||||
@ -707,15 +707,15 @@ pub(crate) fn validate_memory_maintenance_output(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查目标 namespace 是否与源一致
|
// 检查目标 namespace 是否与源一致
|
||||||
if let Some(src_ns) = source_namespaces.iter().next() {
|
if let Some(src_ns) = source_namespaces.iter().next()
|
||||||
if *src_ns != merge.namespace {
|
&& *src_ns != merge.namespace
|
||||||
|
{
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"跨 namespace 合并被禁止: {} → {}",
|
"跨 namespace 合并被禁止: {} → {}",
|
||||||
src_ns, merge.namespace
|
src_ns, merge.namespace
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 3: 总体合并比例
|
// 验证 3: 总体合并比例
|
||||||
let merged_ids: HashSet<&str> = output
|
let merged_ids: HashSet<&str> = output
|
||||||
@ -768,7 +768,7 @@ pub(crate) fn apply_memory_maintenance_output(
|
|||||||
min_memories_to_keep,
|
min_memories_to_keep,
|
||||||
max_merge_per_group,
|
max_merge_per_group,
|
||||||
)
|
)
|
||||||
.map_err(|e| AgentError::Other(e))?;
|
.map_err(AgentError::Other)?;
|
||||||
|
|
||||||
let all_candidates = plan.candidates.clone();
|
let all_candidates = plan.candidates.clone();
|
||||||
|
|
||||||
@ -834,8 +834,9 @@ pub(crate) fn apply_memory_maintenance_output(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for memory_id in &output.low_value_ids {
|
for memory_id in &output.low_value_ids {
|
||||||
if let Some(candidate) = candidates_by_id.get(memory_id.as_str()) {
|
if let Some(candidate) = candidates_by_id.get(memory_id.as_str())
|
||||||
if deleted_ids.insert(candidate.id.clone()) {
|
&& deleted_ids.insert(candidate.id.clone())
|
||||||
|
{
|
||||||
store
|
store
|
||||||
.delete_memory("user", scope_key, &candidate.namespace, &candidate.key)
|
.delete_memory("user", scope_key, &candidate.namespace, &candidate.key)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
@ -843,7 +844,6 @@ pub(crate) fn apply_memory_maintenance_output(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 新增:记录整理完成时间
|
// 新增:记录整理完成时间
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
|||||||
@ -110,8 +110,15 @@ 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, topic_model_selections, subagent_executor) =
|
let (
|
||||||
build_session_manager_with_sender(
|
session_manager,
|
||||||
|
task_repository,
|
||||||
|
mcp_manager,
|
||||||
|
subagent_runtime,
|
||||||
|
model_selections,
|
||||||
|
topic_model_selections,
|
||||||
|
subagent_executor,
|
||||||
|
) = 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(),
|
||||||
@ -226,7 +233,11 @@ pub async fn run(
|
|||||||
// 将 pending_subagents 表中所有 status='running' 的记录标记为 'interrupted'。
|
// 将 pending_subagents 表中所有 status='running' 的记录标记为 'interrupted'。
|
||||||
// 下次 wait_for_subagents 调用时,这些 task_id 不会出现在 pending 列表中,
|
// 下次 wait_for_subagents 调用时,这些 task_id 不会出现在 pending 列表中,
|
||||||
// agent 可据此判断子代理未正常完成。
|
// agent 可据此判断子代理未正常完成。
|
||||||
match state.session_manager.store().mark_all_running_as_interrupted() {
|
match state
|
||||||
|
.session_manager
|
||||||
|
.store()
|
||||||
|
.mark_all_running_as_interrupted()
|
||||||
|
{
|
||||||
Ok(0) => {
|
Ok(0) => {
|
||||||
tracing::info!("Crash recovery: no interrupted subagents to recover");
|
tracing::info!("Crash recovery: no interrupted subagents to recover");
|
||||||
}
|
}
|
||||||
@ -252,7 +263,7 @@ pub async fn run(
|
|||||||
// Initialize and start channels
|
// Initialize and start channels
|
||||||
state
|
state
|
||||||
.channel_manager
|
.channel_manager
|
||||||
.init(&*cfg, provider_config.clone())
|
.init(&cfg, provider_config.clone())
|
||||||
.await?;
|
.await?;
|
||||||
drop(cfg);
|
drop(cfg);
|
||||||
state.channel_manager.start_all().await?;
|
state.channel_manager.start_all().await?;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// per-session 的用户模型覆盖选择存储。
|
/// per-session 的用户模型覆盖选择存储。
|
||||||
///
|
///
|
||||||
@ -17,9 +17,7 @@ impl ModelSelectionStore {
|
|||||||
|
|
||||||
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
|
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
|
||||||
pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
|
pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
|
||||||
let mut selections = self
|
let mut selections = self.selections.write();
|
||||||
.selections
|
|
||||||
.write();
|
|
||||||
if provider.is_none() && model.is_none() {
|
if provider.is_none() && model.is_none() {
|
||||||
selections.remove(session_id);
|
selections.remove(session_id);
|
||||||
} else {
|
} else {
|
||||||
@ -29,10 +27,7 @@ impl ModelSelectionStore {
|
|||||||
|
|
||||||
/// 读取 session 的用户模型覆盖。
|
/// 读取 session 的用户模型覆盖。
|
||||||
pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> {
|
pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> {
|
||||||
self.selections
|
self.selections.read().get(session_id).cloned()
|
||||||
.read()
|
|
||||||
.get(session_id)
|
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,30 +77,18 @@ impl OutboundDispatcher {
|
|||||||
/// sender task 生命周期与 dispatcher 一致:dispatcher `run()` 退出时
|
/// sender task 生命周期与 dispatcher 一致:dispatcher `run()` 退出时
|
||||||
/// 通过 cancel token 终止所有 sender task。
|
/// 通过 cancel token 终止所有 sender task。
|
||||||
pub async fn register_channel(&self, name: &str, channel: Arc<dyn Channel + Send + Sync>) {
|
pub async fn register_channel(&self, name: &str, channel: Arc<dyn Channel + Send + Sync>) {
|
||||||
let (high_tx, high_rx) =
|
let (high_tx, high_rx) = mpsc::channel::<OutboundMessage>(HIGH_PRIORITY_QUEUE_CAPACITY);
|
||||||
mpsc::channel::<OutboundMessage>(HIGH_PRIORITY_QUEUE_CAPACITY);
|
let (low_tx, low_rx) = mpsc::channel::<OutboundMessage>(LOW_PRIORITY_QUEUE_CAPACITY);
|
||||||
let (low_tx, low_rx) =
|
|
||||||
mpsc::channel::<OutboundMessage>(LOW_PRIORITY_QUEUE_CAPACITY);
|
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
|
|
||||||
let channel_name = name.to_string();
|
let channel_name = name.to_string();
|
||||||
let cancel_for_task = cancel.clone();
|
let cancel_for_task = cancel.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
Self::run_sender_task(
|
Self::run_sender_task(&channel_name, channel, high_rx, low_rx, cancel_for_task).await;
|
||||||
&channel_name,
|
|
||||||
channel,
|
|
||||||
high_rx,
|
|
||||||
low_rx,
|
|
||||||
cancel_for_task,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.channels
|
self.channels.write().await.insert(
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(
|
|
||||||
name.to_string(),
|
name.to_string(),
|
||||||
ChannelSink {
|
ChannelSink {
|
||||||
high_tx,
|
high_tx,
|
||||||
@ -166,11 +154,7 @@ impl OutboundDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 发送单条消息,处理重试结果日志。
|
/// 发送单条消息,处理重试结果日志。
|
||||||
async fn send_one(
|
async fn send_one(channel: &dyn Channel, channel_name: &str, msg: OutboundMessage) {
|
||||||
channel: &dyn Channel,
|
|
||||||
channel_name: &str,
|
|
||||||
msg: OutboundMessage,
|
|
||||||
) {
|
|
||||||
let msg_chat_id = msg.chat_id.clone();
|
let msg_chat_id = msg.chat_id.clone();
|
||||||
let msg_trace_id = msg.trace_id.clone();
|
let msg_trace_id = msg.trace_id.clone();
|
||||||
match Self::send_with_retry(channel, msg).await {
|
match Self::send_with_retry(channel, msg).await {
|
||||||
@ -419,7 +403,7 @@ mod tests {
|
|||||||
return Err(ChannelError::ChannelFull);
|
return Err(ChannelError::ChannelFull);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count as u32) < self.fail_first_n {
|
if count < self.fail_first_n {
|
||||||
return Err(ChannelError::SendError("simulated failure".to_string()));
|
return Err(ChannelError::SendError("simulated failure".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -479,19 +463,42 @@ mod tests {
|
|||||||
let error = make_error_message("c", "chat", "agent failed");
|
let error = make_error_message("c", "chat", "agent failed");
|
||||||
let tool_call = make_low_message("c", "chat", "calling tool");
|
let tool_call = make_low_message("c", "chat", "calling tool");
|
||||||
let tool_result = OutboundMessage::tool_result(
|
let tool_result = OutboundMessage::tool_result(
|
||||||
"c", "chat", None, "id", "tool", "result", None,
|
"c",
|
||||||
|
"chat",
|
||||||
|
None,
|
||||||
|
"id",
|
||||||
|
"tool",
|
||||||
|
"result",
|
||||||
|
None,
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
);
|
);
|
||||||
let exec_done = OutboundMessage::execution_completed(
|
let exec_done = OutboundMessage::execution_completed(
|
||||||
"c", "chat", None,
|
"c",
|
||||||
|
"chat",
|
||||||
|
None,
|
||||||
std::collections::HashMap::new(),
|
std::collections::HashMap::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(is_high_priority(&assistant), "AssistantResponse should be high priority");
|
assert!(
|
||||||
assert!(is_high_priority(&error), "ErrorNotification should be high priority");
|
is_high_priority(&assistant),
|
||||||
assert!(!is_high_priority(&tool_call), "ToolCall should be low priority");
|
"AssistantResponse should be high priority"
|
||||||
assert!(!is_high_priority(&tool_result), "ToolResult should be low priority");
|
);
|
||||||
assert!(!is_high_priority(&exec_done), "ExecutionCompleted should be low priority");
|
assert!(
|
||||||
|
is_high_priority(&error),
|
||||||
|
"ErrorNotification should be high priority"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_high_priority(&tool_call),
|
||||||
|
"ToolCall should be low priority"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_high_priority(&tool_result),
|
||||||
|
"ToolResult should be low priority"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_high_priority(&exec_done),
|
||||||
|
"ExecutionCompleted should be low priority"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -514,8 +521,12 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 先发一条 slow(500ms 延迟),紧接着发一条 fast
|
// 先发一条 slow(500ms 延迟),紧接着发一条 fast
|
||||||
bus.publish_outbound(make_message("slow", "chat-1", "slow-msg")).await.unwrap();
|
bus.publish_outbound(make_message("slow", "chat-1", "slow-msg"))
|
||||||
bus.publish_outbound(make_message("fast", "chat-2", "fast-msg")).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
bus.publish_outbound(make_message("fast", "chat-2", "fast-msg"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// 等待 fast 消息被投递(远早于 slow 完成)
|
// 等待 fast 消息被投递(远早于 slow 完成)
|
||||||
tokio::time::timeout(Duration::from_millis(200), async {
|
tokio::time::timeout(Duration::from_millis(200), async {
|
||||||
@ -524,7 +535,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("fast channel should receive message within 200ms, but was blocked by slow channel");
|
.expect(
|
||||||
|
"fast channel should receive message within 200ms, but was blocked by slow channel",
|
||||||
|
);
|
||||||
|
|
||||||
// 等待 slow 消息完成
|
// 等待 slow 消息完成
|
||||||
tokio::time::timeout(Duration::from_secs(2), async {
|
tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
@ -568,8 +581,12 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 先发 flaky(会重试 3 秒),紧接着发 stable
|
// 先发 flaky(会重试 3 秒),紧接着发 stable
|
||||||
bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg")).await.unwrap();
|
bus.publish_outbound(make_message("flaky", "chat-1", "flaky-msg"))
|
||||||
bus.publish_outbound(make_message("stable", "chat-2", "stable-msg")).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
|
bus.publish_outbound(make_message("stable", "chat-2", "stable-msg"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// stable 应在 200ms 内收到,远早于 flaky 的 3 秒重试完成
|
// stable 应在 200ms 内收到,远早于 flaky 的 3 秒重试完成
|
||||||
tokio::time::timeout(Duration::from_millis(200), async {
|
tokio::time::timeout(Duration::from_millis(200), async {
|
||||||
@ -820,7 +837,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("high priority should succeed within extended retry budget");
|
.expect("high priority should succeed within extended retry budget");
|
||||||
|
|
||||||
assert!(result.is_ok(), "high priority should succeed after 4 attempts");
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"high priority should succeed after 4 attempts"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
call_count.load(Ordering::SeqCst),
|
call_count.load(Ordering::SeqCst),
|
||||||
4,
|
4,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use futures_util::FutureExt;
|
use futures_util::FutureExt;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
@ -28,8 +28,8 @@ 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;
|
||||||
|
|
||||||
use super::session::{BusToolCallEmitter, SessionManager};
|
|
||||||
use super::message_prepare::enrich_user_content_with_media_refs;
|
use super::message_prepare::enrich_user_content_with_media_refs;
|
||||||
|
use super::session::{BusToolCallEmitter, SessionManager};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct InboundProcessor {
|
pub struct InboundProcessor {
|
||||||
@ -180,8 +180,7 @@ impl InboundProcessor {
|
|||||||
let chat_id_for_span = inbound.chat_id.clone();
|
let chat_id_for_span = inbound.chat_id.clone();
|
||||||
let session_id_for_span =
|
let session_id_for_span =
|
||||||
crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id);
|
crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id);
|
||||||
tokio::spawn(
|
tokio::spawn(crate::observability::tracing_ctx::traced(
|
||||||
crate::observability::tracing_ctx::traced(
|
|
||||||
&trace_id,
|
&trace_id,
|
||||||
&chat_id_for_span,
|
&chat_id_for_span,
|
||||||
&session_id_for_span,
|
&session_id_for_span,
|
||||||
@ -211,8 +210,7 @@ impl InboundProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,8 +278,8 @@ impl InboundProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(error) = response.error {
|
} else if let Some(error) = response.error
|
||||||
if let Err(e) = self
|
&& let Err(e) = self
|
||||||
.bus
|
.bus
|
||||||
.publish_outbound(
|
.publish_outbound(
|
||||||
OutboundMessage::assistant(
|
OutboundMessage::assistant(
|
||||||
@ -305,7 +303,6 @@ impl InboundProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,8 +323,9 @@ impl InboundProcessor {
|
|||||||
//
|
//
|
||||||
// 安全性:is_waiting 在持锁状态下检查,wait_coordinator 清除 is_waiting 需先重获取锁,
|
// 安全性:is_waiting 在持锁状态下检查,wait_coordinator 清除 is_waiting 需先重获取锁,
|
||||||
// 两者互斥,无 TOCTOU。
|
// 两者互斥,无 TOCTOU。
|
||||||
if let Some(ref topic_id) = current_topic {
|
if let Some(ref topic_id) = current_topic
|
||||||
if let Some(session) = self.session_manager.get(&inbound.channel).await {
|
&& let Some(session) = self.session_manager.get(&inbound.channel).await
|
||||||
|
{
|
||||||
let lock_key = topic_id.clone();
|
let lock_key = topic_id.clone();
|
||||||
|
|
||||||
// 获取 serial_lock Arc(短暂持有 session 锁)
|
// 获取 serial_lock Arc(短暂持有 session 锁)
|
||||||
@ -362,20 +360,12 @@ impl InboundProcessor {
|
|||||||
g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?;
|
g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?;
|
||||||
|
|
||||||
// 构造用户消息(与 prepare_and_execute_message 一致的处理流程)
|
// 构造用户消息(与 prepare_and_execute_message 一致的处理流程)
|
||||||
let media_refs: Vec<String> = inbound
|
let media_refs: Vec<String> =
|
||||||
.media
|
inbound.media.iter().map(|m| m.path.clone()).collect();
|
||||||
.iter()
|
|
||||||
.map(|m| m.path.clone())
|
|
||||||
.collect();
|
|
||||||
let enriched_content =
|
let enriched_content =
|
||||||
enrich_user_content_with_media_refs(&inbound.content, &media_refs)?;
|
enrich_user_content_with_media_refs(&inbound.content, &media_refs)?;
|
||||||
let user_message =
|
let user_message = g.create_user_message(&enriched_content, media_refs);
|
||||||
g.create_user_message(&enriched_content, media_refs);
|
g.append_persisted_message(&inbound.chat_id, Some(&lock_key), user_message)?;
|
||||||
g.append_persisted_message(
|
|
||||||
&inbound.chat_id,
|
|
||||||
Some(&lock_key),
|
|
||||||
user_message,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 获取 wakeup 信号
|
// 获取 wakeup 信号
|
||||||
g.wait_wakeup(&lock_key)
|
g.wait_wakeup(&lock_key)
|
||||||
@ -393,7 +383,6 @@ impl InboundProcessor {
|
|||||||
}
|
}
|
||||||
// is_waiting=false:_inject_guard drop 释放锁,走正常 handle_message 路径
|
// is_waiting=false:_inject_guard drop 释放锁,走正常 handle_message 路径
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new(
|
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new(
|
||||||
BusToolCallEmitter::new(
|
BusToolCallEmitter::new(
|
||||||
@ -461,18 +450,17 @@ impl InboundProcessor {
|
|||||||
// 异步生成 topic 描述(仅当描述为空且没有正在进行的生成任务时触发)
|
// 异步生成 topic 描述(仅当描述为空且没有正在进行的生成任务时触发)
|
||||||
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.is_none()
|
||||||
|| topic
|
|| topic
|
||||||
.description
|
.description
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|d| d.is_empty())
|
.map(|d| d.is_empty())
|
||||||
.unwrap_or(true)
|
.unwrap_or(true))
|
||||||
{
|
{
|
||||||
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
|
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
|
||||||
let should_generate = {
|
let should_generate = {
|
||||||
let mut in_flight =
|
let mut in_flight = self.description_generation_in_flight.lock();
|
||||||
self.description_generation_in_flight.lock();
|
|
||||||
if in_flight.contains(topic_id) {
|
if in_flight.contains(topic_id) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
@ -492,9 +480,7 @@ impl InboundProcessor {
|
|||||||
let first_user_message = store_clone
|
let first_user_message = store_clone
|
||||||
.load_messages_for_topic_full(&topic_id_clone, None)
|
.load_messages_for_topic_full(&topic_id_clone, None)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|msgs| {
|
.and_then(|msgs| msgs.into_iter().find(|m| m.role == "user"))
|
||||||
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 {
|
||||||
@ -506,8 +492,7 @@ impl InboundProcessor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let runtime_config: ProviderRuntimeConfig =
|
let runtime_config: ProviderRuntimeConfig = provider_config.into();
|
||||||
provider_config.into();
|
|
||||||
if let Ok(provider) = create_provider(runtime_config) {
|
if let Ok(provider) = create_provider(runtime_config) {
|
||||||
match generate_topic_description(
|
match generate_topic_description(
|
||||||
provider.as_ref(),
|
provider.as_ref(),
|
||||||
@ -516,12 +501,10 @@ impl InboundProcessor {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(description) => {
|
Ok(description) => {
|
||||||
if let Err(e) = store_clone
|
if let Err(e) = store_clone.update_topic_description(
|
||||||
.update_topic_description(
|
|
||||||
&topic_id_clone,
|
&topic_id_clone,
|
||||||
&description,
|
&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");
|
||||||
@ -539,7 +522,6 @@ impl InboundProcessor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %crate::utils::format_error_chain(&error),
|
error = %crate::utils::format_error_chain(&error),
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
use crate::agent::{AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler};
|
use crate::agent::{
|
||||||
|
AgentError, AgentLoop, AgentRuntimeConfig, ContextCompressor, EmittedMessageHandler,
|
||||||
|
};
|
||||||
#[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};
|
||||||
@ -12,10 +14,10 @@ use crate::storage::{
|
|||||||
SkillEventRepository,
|
SkillEventRepository,
|
||||||
};
|
};
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
|
use crate::tools::WaitCoordinator;
|
||||||
|
use crate::tools::task::SubagentResult;
|
||||||
use crate::tools::task::repository::TaskRepository;
|
use crate::tools::task::repository::TaskRepository;
|
||||||
use crate::tools::task::runtime::SubagentRuntime;
|
use crate::tools::task::runtime::SubagentRuntime;
|
||||||
use crate::tools::task::SubagentResult;
|
|
||||||
use crate::tools::WaitCoordinator;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -557,11 +559,11 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新 topic 的最后活跃时间
|
// 更新 topic 的最后活跃时间
|
||||||
if let Some(ref topic_id) = topic_id {
|
if let Some(ref topic_id) = topic_id
|
||||||
if let Err(e) = self.store.touch_topic(topic_id) {
|
&& let Err(e) = self.store.touch_topic(topic_id)
|
||||||
|
{
|
||||||
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to touch topic");
|
tracing::warn!(error = %e, topic_id = %topic_id, "Failed to touch topic");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -1270,6 +1272,28 @@ impl SessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl crate::scheduler::MaintenanceExecutor for SessionManager {
|
||||||
|
async fn cleanup_expired_sessions(&self) -> usize {
|
||||||
|
self.cleanup_expired_sessions().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_memory_maintenance_for_all_scopes(
|
||||||
|
&self,
|
||||||
|
) -> anyhow::Result<Vec<crate::scheduler::MaintenanceRunSummary>> {
|
||||||
|
match self.run_memory_maintenance_for_all_scopes().await {
|
||||||
|
Ok(Some(result)) => Ok(vec![crate::scheduler::MaintenanceRunSummary {
|
||||||
|
scope_key: result.scope_key,
|
||||||
|
merges: result.output.merges.len(),
|
||||||
|
conflicts: result.output.conflicts.len(),
|
||||||
|
low_value: result.output.low_value_ids.len(),
|
||||||
|
}]),
|
||||||
|
Ok(None) => Ok(vec![]),
|
||||||
|
Err(error) => Err(anyhow::anyhow!(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -3033,25 +3057,3 @@ mod tests {
|
|||||||
assert!(contents.contains(&"习惯先问方案再要代码".to_string()));
|
assert!(contents.contains(&"习惯先问方案再要代码".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl crate::scheduler::MaintenanceExecutor for SessionManager {
|
|
||||||
async fn cleanup_expired_sessions(&self) -> usize {
|
|
||||||
self.cleanup_expired_sessions().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_memory_maintenance_for_all_scopes(
|
|
||||||
&self,
|
|
||||||
) -> anyhow::Result<Vec<crate::scheduler::MaintenanceRunSummary>> {
|
|
||||||
match self.run_memory_maintenance_for_all_scopes().await {
|
|
||||||
Ok(Some(result)) => Ok(vec![crate::scheduler::MaintenanceRunSummary {
|
|
||||||
scope_key: result.scope_key,
|
|
||||||
merges: result.output.merges.len(),
|
|
||||||
conflicts: result.output.conflicts.len(),
|
|
||||||
low_value: result.output.low_value_ids.len(),
|
|
||||||
}]),
|
|
||||||
Ok(None) => Ok(vec![]),
|
|
||||||
Err(error) => Err(anyhow::anyhow!(error.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -76,11 +76,7 @@ impl SessionHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 收集当前活跃 topic 集合
|
// 收集当前活跃 topic 集合
|
||||||
let active: HashSet<&str> = self
|
let active: HashSet<&str> = self.chat_topic_ids.values().map(|s| s.as_str()).collect();
|
||||||
.chat_topic_ids
|
|
||||||
.values()
|
|
||||||
.map(|s| s.as_str())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// 找一个非活跃 topic 驱逐
|
// 找一个非活跃 topic 驱逐
|
||||||
let to_evict = self.topic_histories.keys().find(|tid| {
|
let to_evict = self.topic_histories.keys().find(|tid| {
|
||||||
@ -104,11 +100,11 @@ impl SessionHistory {
|
|||||||
// 检查是否有活跃 agent 任务(serial lock 被持有)
|
// 检查是否有活跃 agent 任务(serial lock 被持有)
|
||||||
// try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐
|
// try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐
|
||||||
// try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐
|
// try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐
|
||||||
if let Some(lock) = self.topic_serial_locks.get(*tid) {
|
if let Some(lock) = self.topic_serial_locks.get(*tid)
|
||||||
if lock.try_lock().is_err() {
|
&& lock.try_lock().is_err()
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -176,7 +172,10 @@ impl SessionHistory {
|
|||||||
|
|
||||||
/// 获取该 topic 的 sub_done 队列 sender(用于后台子代理发送结果)。
|
/// 获取该 topic 的 sub_done 队列 sender(用于后台子代理发送结果)。
|
||||||
/// 调用前应已通过 `ensure_sub_done_channel` 创建队列。
|
/// 调用前应已通过 `ensure_sub_done_channel` 创建队列。
|
||||||
pub(crate) fn sub_done_sender(&mut self, topic_id: &str) -> Option<mpsc::Sender<SubagentResult>> {
|
pub(crate) fn sub_done_sender(
|
||||||
|
&mut self,
|
||||||
|
topic_id: &str,
|
||||||
|
) -> Option<mpsc::Sender<SubagentResult>> {
|
||||||
self.ensure_sub_done_channel(topic_id);
|
self.ensure_sub_done_channel(topic_id);
|
||||||
self.sub_done_senders.get(topic_id).cloned()
|
self.sub_done_senders.get(topic_id).cloned()
|
||||||
}
|
}
|
||||||
@ -334,15 +333,15 @@ impl SessionHistory {
|
|||||||
chat_id: &str,
|
chat_id: &str,
|
||||||
topic_id: Option<&str>,
|
topic_id: Option<&str>,
|
||||||
) -> Result<(), AgentError> {
|
) -> Result<(), AgentError> {
|
||||||
if let Some(tid) = topic_id {
|
if let Some(tid) = topic_id
|
||||||
if let Some(history) = self.topic_histories.get_mut(tid) {
|
&& let Some(history) = self.topic_histories.get_mut(tid)
|
||||||
|
{
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
let len = history.len();
|
let len = history.len();
|
||||||
history.clear();
|
history.clear();
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(topic_id = %tid, previous_len = len, "Topic history cleared");
|
tracing::debug!(topic_id = %tid, previous_len = len, "Topic history cleared");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
self.conversations
|
self.conversations
|
||||||
.clear_messages(&self.persistent_session_id(chat_id))
|
.clear_messages(&self.persistent_session_id(chat_id))
|
||||||
|
|||||||
@ -149,7 +149,7 @@ mod tests {
|
|||||||
text: Some("hello".to_string()),
|
text: Some("hello".to_string()),
|
||||||
// 使用临时目录确保跨平台兼容
|
// 使用临时目录确保跨平台兼容
|
||||||
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",
|
||||||
)],
|
)],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -34,15 +34,15 @@ pub async fn static_handler(uri: Uri) -> Response<Body> {
|
|||||||
None => {
|
None => {
|
||||||
// 对于 SPA 应用,如果请求的是页面路由(不是静态资源),返回 index.html
|
// 对于 SPA 应用,如果请求的是页面路由(不是静态资源),返回 index.html
|
||||||
// 静态资源通常包含 . (如 .js, .css, .png)
|
// 静态资源通常包含 . (如 .js, .css, .png)
|
||||||
if !path.contains('.') {
|
if !path.contains('.')
|
||||||
if let Some(index) = StaticAssets::get("index.html") {
|
&& let Some(index) = StaticAssets::get("index.html")
|
||||||
|
{
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, "text/html")
|
.header(header::CONTENT_TYPE, "text/html")
|
||||||
.body(Body::from(index.data.into_owned()))
|
.body(Body::from(index.data.into_owned()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::NOT_FOUND)
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
|||||||
@ -11,6 +11,12 @@ use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
|||||||
/// - 两者独立演化:新增工具只需在此处加常量,不碰代理身份配置
|
/// - 两者独立演化:新增工具只需在此处加常量,不碰代理身份配置
|
||||||
pub struct ToolPromptProvider;
|
pub struct ToolPromptProvider;
|
||||||
|
|
||||||
|
impl Default for ToolPromptProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ToolPromptProvider {
|
impl ToolPromptProvider {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self
|
||||||
|
|||||||
@ -111,8 +111,9 @@ impl ToolRegistryFactory {
|
|||||||
if self.is_enabled("memory_manage") {
|
if self.is_enabled("memory_manage") {
|
||||||
registry.register(MemoryManageTool::new(self.memories.clone()));
|
registry.register(MemoryManageTool::new(self.memories.clone()));
|
||||||
}
|
}
|
||||||
if self.is_enabled("todo_write") {
|
if self.is_enabled("todo_write")
|
||||||
if let Some(ref state) = self.todo_state {
|
&& let Some(ref state) = self.todo_state
|
||||||
|
{
|
||||||
registry.register(TodoWriteTool::new(
|
registry.register(TodoWriteTool::new(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
self.todo_repository.clone(),
|
self.todo_repository.clone(),
|
||||||
@ -122,7 +123,6 @@ impl ToolRegistryFactory {
|
|||||||
self.todo_repository.clone(),
|
self.todo_repository.clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if self.is_enabled("session_send") {
|
if self.is_enabled("session_send") {
|
||||||
registry.register(SessionSendTool::new(self.session_message_sender.clone()));
|
registry.register(SessionSendTool::new(self.session_message_sender.clone()));
|
||||||
}
|
}
|
||||||
@ -157,8 +157,10 @@ impl ToolRegistryFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 注册 Task 工具(如果启用且有 subagent_runtime)
|
// 注册 Task 工具(如果启用且有 subagent_runtime)
|
||||||
if self.is_enabled("task") && self.task_config.enabled {
|
if self.is_enabled("task")
|
||||||
if let Some(runtime) = &self.subagent_runtime {
|
&& self.task_config.enabled
|
||||||
|
&& let Some(runtime) = &self.subagent_runtime
|
||||||
|
{
|
||||||
registry.register(TaskTool::new(runtime.clone(), None));
|
registry.register(TaskTool::new(runtime.clone(), None));
|
||||||
// 注册 wait_for_subagents 工具(仅主 agent,用于等待异步子代理完成)
|
// 注册 wait_for_subagents 工具(仅主 agent,用于等待异步子代理完成)
|
||||||
// 默认超时从配置读取,LLM 可通过 timeout_secs 参数覆盖
|
// 默认超时从配置读取,LLM 可通过 timeout_secs 参数覆盖
|
||||||
@ -166,7 +168,6 @@ impl ToolRegistryFactory {
|
|||||||
self.task_config.wait_default_timeout_secs,
|
self.task_config.wait_default_timeout_secs,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
@ -230,8 +231,9 @@ impl ToolRegistryFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Todo 追踪工具
|
// Todo 追踪工具
|
||||||
if self.is_enabled("todo_write") {
|
if self.is_enabled("todo_write")
|
||||||
if let Some(ref state) = self.todo_state {
|
&& let Some(ref state) = self.todo_state
|
||||||
|
{
|
||||||
registry.register(TodoWriteTool::new(
|
registry.register(TodoWriteTool::new(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
self.todo_repository.clone(),
|
self.todo_repository.clone(),
|
||||||
@ -241,7 +243,6 @@ impl ToolRegistryFactory {
|
|||||||
self.todo_repository.clone(),
|
self.todo_repository.clone(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 注册 MCP 工具(如果提供)
|
// 注册 MCP 工具(如果提供)
|
||||||
if let Some(mcp_tools) = mcp_tools {
|
if let Some(mcp_tools) = mcp_tools {
|
||||||
|
|||||||
@ -97,11 +97,7 @@ impl WaitCoordinator for SessionWaitCoordinator {
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait(
|
async fn wait(&self, timeout: Duration, cancel_rx: Option<watch::Receiver<()>>) -> WaitEvent {
|
||||||
&self,
|
|
||||||
timeout: Duration,
|
|
||||||
cancel_rx: Option<watch::Receiver<()>>,
|
|
||||||
) -> WaitEvent {
|
|
||||||
// 1. 设置 waiting=true
|
// 1. 设置 waiting=true
|
||||||
{
|
{
|
||||||
let mut session = self.session.lock().await;
|
let mut session = self.session.lock().await;
|
||||||
|
|||||||
@ -142,15 +142,15 @@ pub async fn ws_handler(
|
|||||||
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
|
auth_cfg: Option<axum::Extension<crate::gateway::auth::AuthConfig>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// 若启用了认证(auth_cfg 存在且 token 已配置),校验 query param 中的 token
|
// 若启用了认证(auth_cfg 存在且 token 已配置),校验 query param 中的 token
|
||||||
if let Some(axum::Extension(cfg)) = auth_cfg {
|
if let Some(axum::Extension(cfg)) = auth_cfg
|
||||||
if let Some(ref expected) = cfg.token {
|
&& let Some(ref expected) = cfg.token
|
||||||
|
{
|
||||||
let provided = query.token.as_deref();
|
let provided = query.token.as_deref();
|
||||||
if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) {
|
if !crate::gateway::auth::token_matches(provided, &Some(expected.clone())) {
|
||||||
tracing::warn!("WebSocket connection rejected: missing or invalid token");
|
tracing::warn!("WebSocket connection rejected: missing or invalid token");
|
||||||
return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response();
|
return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ws.on_upgrade(|socket| async {
|
ws.on_upgrade(|socket| async {
|
||||||
handle_socket(socket, state).await;
|
handle_socket(socket, state).await;
|
||||||
@ -653,26 +653,24 @@ async fn handle_inbound(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理定时任务列表
|
// 处理定时任务列表
|
||||||
if let Some(jobs_json) = response.metadata.get("scheduler_jobs") {
|
if let Some(jobs_json) = response.metadata.get("scheduler_jobs")
|
||||||
if let Ok(jobs) =
|
&& let Ok(jobs) =
|
||||||
serde_json::from_str::<Vec<crate::protocol::SchedulerJobSummary>>(jobs_json)
|
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) =
|
&& let Ok(skills) =
|
||||||
serde_json::from_str::<Vec<crate::protocol::SkillSummary>>(skills_json)
|
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) =
|
&& let Ok(todos) =
|
||||||
serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
|
serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json)
|
||||||
{
|
{
|
||||||
let scope_key = response
|
let scope_key = response
|
||||||
@ -683,20 +681,18 @@ async fn handle_inbound(
|
|||||||
tracing::debug!(todo_count = todos.len(), %scope_key, "list_todos command response");
|
tracing::debug!(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;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 处理记忆列表
|
// 处理记忆列表
|
||||||
if let Some(memories_json) = response.metadata.get("memories") {
|
if let Some(memories_json) = response.metadata.get("memories")
|
||||||
if let Ok(memories) =
|
&& let Ok(memories) =
|
||||||
serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json)
|
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) =
|
&& let Ok(records) =
|
||||||
store.list_memories_for_scope("user", crate::storage::GLOBAL_SCOPE_KEY)
|
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
|
||||||
@ -713,7 +709,6 @@ async fn handle_inbound(
|
|||||||
.collect();
|
.collect();
|
||||||
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
let _ = sender.send(WsOutbound::MemoryList { memories }).await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 处理加载聊天消息请求
|
// 处理加载聊天消息请求
|
||||||
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
if let Some(load_chat_id) = response.metadata.get("load_chat_id") {
|
||||||
@ -738,11 +733,10 @@ 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") {
|
&& 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();
|
||||||
@ -765,7 +759,6 @@ async fn handle_inbound(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if let Some(ref error) = response.error {
|
} else if let Some(ref error) = response.error {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error_code = %error.code,
|
error_code = %error.code,
|
||||||
@ -820,12 +813,12 @@ async fn send_topic_history(
|
|||||||
let mut tool_call_ids_with_results: std::collections::HashSet<String> =
|
let mut tool_call_ids_with_results: std::collections::HashSet<String> =
|
||||||
std::collections::HashSet::new();
|
std::collections::HashSet::new();
|
||||||
for msg in &messages {
|
for msg in &messages {
|
||||||
if msg.role == "tool" {
|
if msg.role == "tool"
|
||||||
if let Some(ref tcid) = msg.tool_call_id {
|
&& let Some(ref tcid) = msg.tool_call_id
|
||||||
|
{
|
||||||
tool_call_ids_with_results.insert(tcid.clone());
|
tool_call_ids_with_results.insert(tcid.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 将消息转换为 WsOutbound 并发送
|
// 将消息转换为 WsOutbound 并发送
|
||||||
for msg in messages {
|
for msg in messages {
|
||||||
@ -894,7 +887,8 @@ fn reconcile_running_in_messages(
|
|||||||
topic_id: &str,
|
topic_id: &str,
|
||||||
) {
|
) {
|
||||||
let has_running_placeholder = messages.iter().any(|m| {
|
let has_running_placeholder = messages.iter().any(|m| {
|
||||||
m.role == "tool" && crate::gateway::session::extract_task_id_from_content(&m.content).is_some()
|
m.role == "tool"
|
||||||
|
&& crate::gateway::session::extract_task_id_from_content(&m.content).is_some()
|
||||||
});
|
});
|
||||||
if !has_running_placeholder {
|
if !has_running_placeholder {
|
||||||
return; // 无需查询 DB
|
return; // 无需查询 DB
|
||||||
@ -916,13 +910,15 @@ fn reconcile_running_in_messages(
|
|||||||
if msg.role != "tool" {
|
if msg.role != "tool" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some((task_id, is_json)) = crate::gateway::session::extract_task_id_from_content(&msg.content)
|
let Some((task_id, is_json)) =
|
||||||
|
crate::gateway::session::extract_task_id_from_content(&msg.content)
|
||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
match status_map.get(task_id.as_str()) {
|
match status_map.get(task_id.as_str()) {
|
||||||
Some(&status) if status != "running" => {
|
Some(&status) if status != "running" => {
|
||||||
msg.content = crate::gateway::session::format_reconciled_content(&task_id, status, is_json);
|
msg.content =
|
||||||
|
crate::gateway::session::format_reconciled_content(&task_id, status, is_json);
|
||||||
}
|
}
|
||||||
_ => {} // 不存在(已清理)或仍在运行:保留原占位
|
_ => {} // 不存在(已清理)或仍在运行:保留原占位
|
||||||
}
|
}
|
||||||
@ -945,12 +941,12 @@ async fn send_task_messages(
|
|||||||
let mut tool_call_ids_with_results: std::collections::HashSet<String> =
|
let mut tool_call_ids_with_results: std::collections::HashSet<String> =
|
||||||
std::collections::HashSet::new();
|
std::collections::HashSet::new();
|
||||||
for msg in &messages {
|
for msg in &messages {
|
||||||
if msg.role == "tool" {
|
if msg.role == "tool"
|
||||||
if let Some(ref tcid) = msg.tool_call_id {
|
&& let Some(ref tcid) = msg.tool_call_id
|
||||||
|
{
|
||||||
tool_call_ids_with_results.insert(tcid.clone());
|
tool_call_ids_with_results.insert(tcid.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for msg in messages {
|
for msg in messages {
|
||||||
let mut outbounds = chat_message_to_ws_outbound(&msg);
|
let mut outbounds = chat_message_to_ws_outbound(&msg);
|
||||||
@ -1041,11 +1037,11 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) {
|
|||||||
fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option<String> {
|
fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option<String> {
|
||||||
let parent = &task.parent_session_id;
|
let parent = &task.parent_session_id;
|
||||||
// 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}")
|
// 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}")
|
||||||
if parent.starts_with("sub:") {
|
if parent.starts_with("sub:")
|
||||||
if let Some(pos) = parent.find(":task:") {
|
&& let Some(pos) = parent.find(":task:")
|
||||||
|
{
|
||||||
return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
|
return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ use chrono_tz::Tz;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
|
EnvFilter, Layer, fmt, fmt::time::FormatTime, layer::SubscriberExt, util::SubscriberInitExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::LogFormat;
|
use crate::config::LogFormat;
|
||||||
@ -61,15 +61,15 @@ pub fn init_logging(timezone: Tz, log_format: LogFormat) {
|
|||||||
let log_dir = get_default_log_dir();
|
let log_dir = get_default_log_dir();
|
||||||
|
|
||||||
// Create log directory if it doesn't exist
|
// Create log directory if it doesn't exist
|
||||||
if !log_dir.exists() {
|
if !log_dir.exists()
|
||||||
if let Err(e) = std::fs::create_dir_all(&log_dir) {
|
&& let Err(e) = std::fs::create_dir_all(&log_dir)
|
||||||
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Warning: Failed to create log directory {}: {}",
|
"Warning: Failed to create log directory {}: {}",
|
||||||
log_dir.display(),
|
log_dir.display(),
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Create file appender with daily rotation
|
// Create file appender with daily rotation
|
||||||
let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log");
|
let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log");
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
//! - Connects to MCP servers asynchronously
|
//! - Connects to MCP servers asynchronously
|
||||||
//! - Dynamically registers MCP tools via the Tool trait adapter
|
//! - Dynamically registers MCP tools via the Tool trait adapter
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use parking_lot::Mutex;
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use http::{HeaderName, HeaderValue};
|
use http::{HeaderName, HeaderValue};
|
||||||
|
|||||||
@ -102,7 +102,7 @@ impl McpServerConfig {
|
|||||||
command,
|
command,
|
||||||
args: self.args.clone().unwrap_or_default(),
|
args: self.args.clone().unwrap_or_default(),
|
||||||
env: self.env.clone().unwrap_or_default(),
|
env: self.env.clone().unwrap_or_default(),
|
||||||
cwd: self.cwd.as_ref().map(|s| std::path::PathBuf::from(s)),
|
cwd: self.cwd.as_ref().map(std::path::PathBuf::from),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"http" | "streamableHttp" => {
|
"http" | "streamableHttp" => {
|
||||||
|
|||||||
@ -111,10 +111,7 @@ impl PicoBotTool for McpToolWrapper {
|
|||||||
.call_tool(&self.server_key, &self.tool_name, args);
|
.call_tool(&self.server_key, &self.tool_name, args);
|
||||||
|
|
||||||
let result = if self.timeout_secs > 0 {
|
let result = if self.timeout_secs > 0 {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(std::time::Duration::from_secs(self.timeout_secs), call)
|
||||||
std::time::Duration::from_secs(self.timeout_secs),
|
|
||||||
call,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@ -183,12 +180,8 @@ 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 =
|
||||||
manager.clone(),
|
McpToolWrapper::new(manager.clone(), server_key.clone(), tool_info, timeout_secs);
|
||||||
server_key.clone(),
|
|
||||||
tool_info,
|
|
||||||
timeout_secs,
|
|
||||||
);
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
name = %wrapper.name(),
|
name = %wrapper.name(),
|
||||||
|
|||||||
@ -40,7 +40,8 @@ pub const MESSAGE_PROCESSING_ERRORS: &str = "picobot_message_processing_errors_t
|
|||||||
/// 幂等:首次调用安装 recorder 并缓存 handle;后续调用(含热重启)返回缓存的 handle。
|
/// 幂等:首次调用安装 recorder 并缓存 handle;后续调用(含热重启)返回缓存的 handle。
|
||||||
/// 这避免了热重启后 `install_recorder()` 因 recorder 已安装而失败、导致 `/metrics` 返回 503 的问题。
|
/// 这避免了热重启后 `install_recorder()` 因 recorder 已安装而失败、导致 `/metrics` 返回 503 的问题。
|
||||||
/// 返回 None 表示安装失败(非致命,metrics 静默降级)。
|
/// 返回 None 表示安装失败(非致命,metrics 静默降级)。
|
||||||
static PROMETHEUS_HANDLE: std::sync::OnceLock<Option<PrometheusHandle>> = std::sync::OnceLock::new();
|
static PROMETHEUS_HANDLE: std::sync::OnceLock<Option<PrometheusHandle>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
pub fn init_recorder() -> Option<PrometheusHandle> {
|
pub fn init_recorder() -> Option<PrometheusHandle> {
|
||||||
PROMETHEUS_HANDLE
|
PROMETHEUS_HANDLE
|
||||||
|
|||||||
@ -342,7 +342,7 @@ pub fn home_dir() -> Option<PathBuf> {
|
|||||||
// Windows: support USERPROFILE
|
// Windows: support USERPROFILE
|
||||||
env::var_os("USERPROFILE").map(PathBuf::from)
|
env::var_os("USERPROFILE").map(PathBuf::from)
|
||||||
})
|
})
|
||||||
.or_else(|| dirs::home_dir())
|
.or_else(dirs::home_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。
|
/// 返回 PicoBot 主目录,若无法确定则回退到当前目录 `"."`。
|
||||||
|
|||||||
@ -125,7 +125,6 @@ pub struct AnthropicProvider {
|
|||||||
api_key: String,
|
api_key: String,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
extra_headers: HashMap<String, String>,
|
extra_headers: HashMap<String, String>,
|
||||||
#[cfg_attr(not(debug_assertions), allow(dead_code))]
|
|
||||||
llm_timeout_secs: u64,
|
llm_timeout_secs: u64,
|
||||||
model_id: String,
|
model_id: String,
|
||||||
temperature: Option<f32>,
|
temperature: Option<f32>,
|
||||||
@ -316,15 +315,15 @@ impl LLMProvider for AnthropicProvider {
|
|||||||
req_builder = req_builder.header(key.as_str(), value.as_str());
|
req_builder = req_builder.header(key.as_str(), value.as_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = req_builder.json(&body).send().await.map_err(|e| {
|
let resp = req_builder.json(&body).send().await.inspect_err(|e| {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
provider = %self.name,
|
provider = %self.name,
|
||||||
model = %self.model_id,
|
model = %self.model_id,
|
||||||
url = %url,
|
url = %url,
|
||||||
error = %format_error_chain(&e),
|
timeout_secs = self.llm_timeout_secs,
|
||||||
|
error = %format_error_chain(e),
|
||||||
"Anthropic: HTTP request failed"
|
"Anthropic: HTTP request failed"
|
||||||
);
|
);
|
||||||
e
|
|
||||||
})?;
|
})?;
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let text = resp.text().await?;
|
let text = resp.text().await?;
|
||||||
@ -635,7 +634,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_format_error_chain_single() {
|
fn test_format_error_chain_single() {
|
||||||
let err = std::io::Error::new(std::io::ErrorKind::Other, "single error");
|
let err = std::io::Error::other("single error");
|
||||||
let chain = format_error_chain(&err);
|
let chain = format_error_chain(&err);
|
||||||
assert_eq!(chain, "single error");
|
assert_eq!(chain, "single error");
|
||||||
}
|
}
|
||||||
@ -649,7 +648,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_format_error_chain_nested() {
|
fn test_format_error_chain_nested() {
|
||||||
let inner = std::io::Error::new(std::io::ErrorKind::Other, "root cause");
|
let inner = std::io::Error::other("root cause");
|
||||||
let outer = OuterError::Wrapped(inner);
|
let outer = OuterError::Wrapped(inner);
|
||||||
let chain = format_error_chain(&outer);
|
let chain = format_error_chain(&outer);
|
||||||
assert!(chain.contains("outer wrapper"));
|
assert!(chain.contains("outer wrapper"));
|
||||||
|
|||||||
@ -63,23 +63,20 @@ impl StreamingAccumulator {
|
|||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
arguments: Option<&str>,
|
arguments: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let entry = self
|
let entry = self.tool_calls.entry(index).or_default();
|
||||||
.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
|
||||||
if !id.is_empty() {
|
&& !id.is_empty()
|
||||||
|
{
|
||||||
entry.id = id.to_string();
|
entry.id = id.to_string();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值
|
// 只在 name 非空时才更新,防止流式响应中后续 chunk 的 None 覆盖之前的值
|
||||||
if let Some(name) = name {
|
if let Some(name) = name
|
||||||
if !name.is_empty() {
|
&& !name.is_empty()
|
||||||
|
{
|
||||||
entry.name = name.to_string();
|
entry.name = name.to_string();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if let Some(args) = arguments {
|
if let Some(args) = arguments {
|
||||||
entry.arguments.push_str(args);
|
entry.arguments.push_str(args);
|
||||||
}
|
}
|
||||||
@ -107,8 +104,8 @@ impl StreamingAccumulator {
|
|||||||
.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)| {
|
||||||
let arguments = serde_json::from_str(&call.arguments)
|
let arguments =
|
||||||
.unwrap_or_else(|_| serde_json::Value::Null);
|
serde_json::from_str(&call.arguments).unwrap_or(serde_json::Value::Null);
|
||||||
ToolCall {
|
ToolCall {
|
||||||
id: call.id,
|
id: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
@ -218,26 +215,24 @@ fn convert_content_blocks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果只有一个文本块且没有通知,返回字符串形式
|
// 如果只有一个文本块且没有通知,返回字符串形式
|
||||||
if converted_blocks.len() == 1 {
|
if converted_blocks.len() == 1
|
||||||
if let Some(block) = converted_blocks.first() {
|
&& let Some(block) = converted_blocks.first()
|
||||||
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
|
&& block.get("type").and_then(|t| t.as_str()) == Some("text")
|
||||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
&& let Some(text) = block.get("text").and_then(|t| t.as_str())
|
||||||
|
{
|
||||||
return Value::String(text.to_string());
|
return Value::String(text.to_string());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Value::Array(converted_blocks);
|
return Value::Array(converted_blocks);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原有逻辑 - 模型支持图片,正常转换
|
// 原有逻辑 - 模型支持图片,正常转换
|
||||||
if blocks.len() == 1 {
|
if blocks.len() == 1
|
||||||
if let ContentBlock::Text { text } = &blocks[0] {
|
&& let ContentBlock::Text { text } = &blocks[0]
|
||||||
|
{
|
||||||
return Value::String(text.clone());
|
return Value::String(text.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Value::Array(
|
Value::Array(
|
||||||
blocks
|
blocks
|
||||||
.iter()
|
.iter()
|
||||||
@ -481,15 +476,13 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 提取流式末帧的 usage(stream_options.include_usage=true 时返回)
|
// 提取流式末帧的 usage(stream_options.include_usage=true 时返回)
|
||||||
if let Some(usage_val) = json.get("usage") {
|
if let Some(usage_val) = json.get("usage")
|
||||||
if !usage_val.is_null() {
|
&& !usage_val.is_null()
|
||||||
if let Ok(u) =
|
&& let Ok(u) =
|
||||||
serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
||||||
{
|
{
|
||||||
accumulator.set_usage(u);
|
accumulator.set_usage(u);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 choices
|
// 提取 choices
|
||||||
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
|
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
|
||||||
@ -605,14 +598,12 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 提取流式末帧的 usage(与主循环一致)
|
// 提取流式末帧的 usage(与主循环一致)
|
||||||
if let Some(usage_val) = json.get("usage") {
|
if let Some(usage_val) = json.get("usage")
|
||||||
if !usage_val.is_null() {
|
&& !usage_val.is_null()
|
||||||
if let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
&& let Ok(u) = serde_json::from_value::<OpenAIUsage>(usage_val.clone())
|
||||||
{
|
{
|
||||||
accumulator.set_usage(u);
|
accumulator.set_usage(u);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
|
if let Some(choices) = json.get("choices").and_then(|c| c.as_array()) {
|
||||||
for choice in choices {
|
for choice in choices {
|
||||||
@ -684,8 +675,10 @@ impl OpenAIProvider {
|
|||||||
|
|
||||||
// 回退:当流式解析未获取到任何内容且无 tool call 时,
|
// 回退:当流式解析未获取到任何内容且无 tool call 时,
|
||||||
// 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体
|
// 服务器可能返回的是非 SSE 格式的纯 JSON,尝试直接反序列化整个响应体
|
||||||
if response.content.is_empty() && response.tool_calls.is_empty() {
|
if response.content.is_empty()
|
||||||
if let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body) {
|
&& response.tool_calls.is_empty()
|
||||||
|
&& let Ok(openai_resp) = serde_json::from_str::<OpenAIResponse>(&raw_body)
|
||||||
|
{
|
||||||
let fallback_content = openai_resp
|
let fallback_content = openai_resp
|
||||||
.choices
|
.choices
|
||||||
.first()
|
.first()
|
||||||
@ -732,7 +725,6 @@ impl OpenAIProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
content_len = response.content.len(),
|
content_len = response.content.len(),
|
||||||
@ -761,15 +753,16 @@ impl OpenAIProvider {
|
|||||||
std::collections::HashSet::new();
|
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"
|
||||||
if let Some(ref tc_id) = m.tool_call_id {
|
&& let Some(ref tc_id) = m.tool_call_id
|
||||||
|
{
|
||||||
resolved_tool_ids.insert(tc_id.as_str());
|
resolved_tool_ids.insert(tc_id.as_str());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if m.role == "assistant" {
|
if m.role == "assistant"
|
||||||
if let Some(ref calls) = m.tool_calls {
|
&& let Some(ref calls) = m.tool_calls
|
||||||
if !calls.is_empty() {
|
&& !calls.is_empty()
|
||||||
|
{
|
||||||
let all_resolved = calls
|
let all_resolved = calls
|
||||||
.iter()
|
.iter()
|
||||||
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
.all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
||||||
@ -782,8 +775,6 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward-order check: verify tool messages IMMEDIATELY follow the
|
// Forward-order check: verify tool messages IMMEDIATELY follow the
|
||||||
// assistant(tool_calls). If any non-tool message appears between the
|
// assistant(tool_calls). If any non-tool message appears between the
|
||||||
@ -827,25 +818,27 @@ 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() && !skip_assistant_indices.contains(&i) {
|
&& !calls.is_empty()
|
||||||
|
&& !skip_assistant_indices.contains(&i)
|
||||||
|
{
|
||||||
pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect();
|
pending_tool_ids = calls.iter().map(|tc| tc.id.as_str()).collect();
|
||||||
pending_assistant_idx = Some(i);
|
pending_assistant_idx = Some(i);
|
||||||
}
|
}
|
||||||
}
|
} else if m.role == "tool"
|
||||||
} else if m.role == "tool" {
|
&& let Some(ref tc_id) = m.tool_call_id
|
||||||
if let Some(ref tc_id) = m.tool_call_id {
|
{
|
||||||
pending_tool_ids.remove(tc_id.as_str());
|
pending_tool_ids.remove(tc_id.as_str());
|
||||||
if pending_tool_ids.is_empty() {
|
if pending_tool_ids.is_empty() {
|
||||||
pending_assistant_idx = None;
|
pending_assistant_idx = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Handle trailing assistant with unresolved immediate tool results
|
// Handle trailing assistant with unresolved immediate tool results
|
||||||
if !pending_tool_ids.is_empty() {
|
if !pending_tool_ids.is_empty()
|
||||||
if let Some(idx) = pending_assistant_idx {
|
&& let Some(idx) = pending_assistant_idx
|
||||||
|
{
|
||||||
skip_assistant_indices.insert(idx);
|
skip_assistant_indices.insert(idx);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
message_index = idx,
|
message_index = idx,
|
||||||
@ -860,7 +853,6 @@ impl OpenAIProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
|
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
|
||||||
// whose parent assistant has ALL results after it)
|
// whose parent assistant has ALL results after it)
|
||||||
@ -956,11 +948,10 @@ impl OpenAIProvider {
|
|||||||
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
|
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
|
||||||
});
|
});
|
||||||
|
|
||||||
if m.role == "assistant" {
|
if m.role == "assistant"
|
||||||
if let Some(reasoning_content) = &m.reasoning_content {
|
&& let Some(reasoning_content) = &m.reasoning_content {
|
||||||
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Some(message)
|
Some(message)
|
||||||
}
|
}
|
||||||
@ -1150,8 +1141,8 @@ impl LLMProvider for OpenAIProvider {
|
|||||||
for (i, msg) in msgs.iter().enumerate() {
|
for (i, msg) in msgs.iter().enumerate() {
|
||||||
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
|
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
|
||||||
for (j, item) in content.iter().enumerate() {
|
for (j, item) in content.iter().enumerate() {
|
||||||
if item.get("type").and_then(|t| t.as_str()) == Some("image_url") {
|
if item.get("type").and_then(|t| t.as_str()) == Some("image_url")
|
||||||
if let Some(url_str) = item
|
&& let Some(url_str) = item
|
||||||
.get("image_url")
|
.get("image_url")
|
||||||
.and_then(|u| u.get("url"))
|
.and_then(|u| u.get("url"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@ -1164,7 +1155,6 @@ impl LLMProvider for OpenAIProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut req_builder = self
|
let mut req_builder = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
@ -734,14 +734,14 @@ impl RuntimeJob {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(max_runs) = self.max_runs {
|
if let Some(max_runs) = self.max_runs
|
||||||
if self.run_count >= max_runs {
|
&& self.run_count >= max_runs
|
||||||
|
{
|
||||||
self.state = SchedulerJobState::Completed;
|
self.state = SchedulerJobState::Completed;
|
||||||
self.next_fire_at = None;
|
self.next_fire_at = None;
|
||||||
self.completed_at = Some(now.timestamp_millis());
|
self.completed_at = Some(now.timestamp_millis());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let reference_ms = self.next_fire_at.or(self.last_fired_at);
|
let reference_ms = self.next_fire_at.or(self.last_fired_at);
|
||||||
self.state = SchedulerJobState::Scheduled;
|
self.state = SchedulerJobState::Scheduled;
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
use crate::platform::{
|
use crate::platform::{
|
||||||
atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape,
|
atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape,
|
||||||
};
|
};
|
||||||
|
use parking_lot::RwLock;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use parking_lot::RwLock;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
@ -143,9 +143,7 @@ impl SkillRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.catalog
|
self.catalog.read().is_empty()
|
||||||
.read()
|
|
||||||
.is_empty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
@ -153,9 +151,7 @@ impl SkillRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn system_index_prompt(&self) -> Option<String> {
|
pub fn system_index_prompt(&self) -> Option<String> {
|
||||||
self.catalog
|
self.catalog.read().system_index_prompt()
|
||||||
.read()
|
|
||||||
.system_index_prompt()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。
|
/// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。
|
||||||
@ -170,34 +166,23 @@ impl SkillRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
||||||
self.catalog
|
self.catalog.read().discovery_event_payload()
|
||||||
.read()
|
|
||||||
.discovery_event_payload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn offered_event_payload(&self) -> serde_json::Value {
|
pub fn offered_event_payload(&self) -> serde_json::Value {
|
||||||
self.catalog
|
self.catalog.read().offered_event_payload()
|
||||||
.read()
|
|
||||||
.offered_event_payload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
|
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
|
||||||
self.catalog
|
self.catalog.read().activation_payload(name)
|
||||||
.read()
|
|
||||||
.activation_payload(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
|
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
|
||||||
self.catalog
|
self.catalog.read().activation_event_payload(name)
|
||||||
.read()
|
|
||||||
.activation_event_payload(name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_skills(&self) -> Vec<Skill> {
|
pub fn list_skills(&self) -> Vec<Skill> {
|
||||||
self.catalog
|
self.catalog.read().skills.clone()
|
||||||
.read()
|
|
||||||
.skills
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all discovered skills including disabled ones, with their disabled scopes.
|
/// List all discovered skills including disabled ones, with their disabled scopes.
|
||||||
@ -226,10 +211,7 @@ impl SkillRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
||||||
self.catalog
|
self.catalog.read().find_skill(name).cloned()
|
||||||
.read()
|
|
||||||
.find_skill(name)
|
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_skill(
|
pub fn create_skill(
|
||||||
@ -450,7 +432,7 @@ impl SkillCatalog {
|
|||||||
// Load from least specific to most specific so later sources win on conflicts.
|
// Load from least specific to most specific so later sources win on conflicts.
|
||||||
for source in source_order(&config.sources) {
|
for source in source_order(&config.sources) {
|
||||||
sources_seen += 1;
|
sources_seen += 1;
|
||||||
let root = source_root(&source, &cwd);
|
let root = source_root(&source, cwd);
|
||||||
|
|
||||||
let Some(root) = root else { continue };
|
let Some(root) = root else { continue };
|
||||||
for skill in load_skills_from_root(&root, source.clone()) {
|
for skill in load_skills_from_root(&root, source.clone()) {
|
||||||
@ -519,7 +501,7 @@ impl SkillCatalog {
|
|||||||
.filter(|s| {
|
.filter(|s| {
|
||||||
allowed_set
|
allowed_set
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(true, |set| set.contains(s.name.as_str()))
|
.is_none_or(|set| set.contains(s.name.as_str()))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@ -299,7 +299,7 @@ pub(super) fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Column migration: add created_by_message_id if it doesn't exist
|
// Column migration: add created_by_message_id if it doesn't exist
|
||||||
let has_column = has_column(&conn, "todos", "created_by_message_id")?;
|
let has_column = has_column(conn, "todos", "created_by_message_id")?;
|
||||||
if !has_column {
|
if !has_column {
|
||||||
tracing::info!("Adding created_by_message_id column to todos table");
|
tracing::info!("Adding created_by_message_id column to todos table");
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@ -574,9 +574,7 @@ impl SessionStore {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, provider, model FROM topics WHERE provider IS NOT NULL OR model IS NOT NULL",
|
"SELECT id, provider, model FROM topics WHERE provider IS NOT NULL OR model IS NOT NULL",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
|
||||||
})?;
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
result.push(row?);
|
result.push(row?);
|
||||||
@ -1027,7 +1025,7 @@ impl SessionStore {
|
|||||||
new_messages.iter().partition(|m| {
|
new_messages.iter().partition(|m| {
|
||||||
m.system_context
|
m.system_context
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map_or(false, |sc| sc.starts_with("history_compaction"))
|
.is_some_and(|sc| sc.starts_with("history_compaction"))
|
||||||
});
|
});
|
||||||
|
|
||||||
// 先删除该 topic 下已有的旧压缩摘要(system_context LIKE 'history_compaction%')。
|
// 先删除该 topic 下已有的旧压缩摘要(system_context LIKE 'history_compaction%')。
|
||||||
|
|||||||
@ -186,7 +186,9 @@ pub struct MemoryUpsert {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum SchedulerJobState {
|
pub enum SchedulerJobState {
|
||||||
|
#[default]
|
||||||
Scheduled,
|
Scheduled,
|
||||||
Running,
|
Running,
|
||||||
Paused,
|
Paused,
|
||||||
@ -241,12 +243,6 @@ impl SchedulerJobStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SchedulerJobState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Scheduled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SchedulerJobRecord {
|
pub struct SchedulerJobRecord {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@ -92,7 +92,7 @@ impl ShellKind {
|
|||||||
let info = self.to_info();
|
let info = self.to_info();
|
||||||
info.args
|
info.args
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| *s)
|
.copied()
|
||||||
.chain(std::iter::once(command))
|
.chain(std::iter::once(command))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@ -385,7 +385,7 @@ impl Tool for BashTool {
|
|||||||
let cwd = self
|
let cwd = self
|
||||||
.working_dir
|
.working_dir
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|d| Path::new(d))
|
.map(Path::new)
|
||||||
.unwrap_or_else(|| Path::new("."));
|
.unwrap_or_else(|| Path::new("."));
|
||||||
|
|
||||||
match self
|
match self
|
||||||
@ -629,7 +629,7 @@ fn format_command_output(stdout: &str, stderr: &str, exit_code: Option<i32>) ->
|
|||||||
|
|
||||||
if !stderr.trim().is_empty() {
|
if !stderr.trim().is_empty() {
|
||||||
if !output.is_empty() {
|
if !output.is_empty() {
|
||||||
output.push_str("\n");
|
output.push('\n');
|
||||||
}
|
}
|
||||||
output.push_str("STDERR:\n");
|
output.push_str("STDERR:\n");
|
||||||
output.push_str(stderr);
|
output.push_str(stderr);
|
||||||
|
|||||||
@ -432,7 +432,9 @@ fn calc_evaluate(args: &serde_json::Value) -> Result<String, String> {
|
|||||||
// 表达式可产生非有限结果(如 "1/0" → inf、"0/0" → NaN),
|
// 表达式可产生非有限结果(如 "1/0" → inf、"0/0" → NaN),
|
||||||
// 与 extract_values/extract_f64 的边界策略保持一致:拒绝输出。
|
// 与 extract_values/extract_f64 的边界策略保持一致:拒绝输出。
|
||||||
if !n.is_finite() {
|
if !n.is_finite() {
|
||||||
return Err(format!("Expression result is not a finite number: {expression}"));
|
return Err(format!(
|
||||||
|
"Expression result is not a finite number: {expression}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(format_num(n))
|
Ok(format_num(n))
|
||||||
})
|
})
|
||||||
@ -873,10 +875,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(ok.success);
|
assert!(ok.success);
|
||||||
assert_eq!(
|
assert_eq!(ok.output, "295232799039604140847618609643520000000");
|
||||||
ok.output,
|
|
||||||
"295232799039604140847618609643520000000"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -140,17 +140,16 @@ impl Tool for FileWriteTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create parent directories if needed
|
// Create parent directories if needed
|
||||||
if let Some(parent) = resolved.parent() {
|
if let Some(parent) = resolved.parent()
|
||||||
if !parent.exists() {
|
&& !parent.exists()
|
||||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
&& let Err(e) = std::fs::create_dir_all(parent)
|
||||||
|
{
|
||||||
return Ok(ToolResult {
|
return Ok(ToolResult {
|
||||||
success: false,
|
success: false,
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
error: Some(format!("Failed to create parent directory: {}", e)),
|
error: Some(format!("Failed to create parent directory: {}", e)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match std::fs::write(&resolved, content) {
|
match std::fs::write(&resolved, content) {
|
||||||
Ok(_) => Ok(ToolResult {
|
Ok(_) => Ok(ToolResult {
|
||||||
|
|||||||
@ -76,15 +76,14 @@ impl HttpRequestTool {
|
|||||||
|
|
||||||
if let Some(obj) = headers.as_object() {
|
if let Some(obj) = headers.as_object() {
|
||||||
for (key, value) in obj {
|
for (key, value) in obj {
|
||||||
if let Some(str_val) = value.as_str() {
|
if let Some(str_val) = value.as_str()
|
||||||
if let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
|
&& let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes())
|
||||||
if let Ok(val) = reqwest::header::HeaderValue::from_str(str_val) {
|
&& let Ok(val) = reqwest::header::HeaderValue::from_str(str_val)
|
||||||
|
{
|
||||||
header_map.insert(name, val);
|
header_map.insert(name, val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
header_map
|
header_map
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,11 +55,8 @@ pub fn extract_string(args: &serde_json::Value, key: &str) -> Option<String> {
|
|||||||
args.get(key).and_then(|v| {
|
args.get(key).and_then(|v| {
|
||||||
if let Some(s) = v.as_str() {
|
if let Some(s) = v.as_str() {
|
||||||
Some(s.to_string())
|
Some(s.to_string())
|
||||||
} else if let Some(n) = v.as_number() {
|
|
||||||
// Handle case where LLM sends a number but we need a string
|
|
||||||
Some(n.to_string())
|
|
||||||
} else {
|
} else {
|
||||||
None
|
v.as_number().map(|n| n.to_string())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
|
use parking_lot::RwLock;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use parking_lot::RwLock;
|
|
||||||
|
|
||||||
use crate::domain::tools::{Tool, ToolFunction};
|
use crate::domain::tools::{Tool, ToolFunction};
|
||||||
|
|
||||||
@ -24,20 +24,13 @@ impl ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, name: &str) -> Option<Arc<dyn ToolTrait>> {
|
pub fn get(&self, name: &str) -> Option<Arc<dyn ToolTrait>> {
|
||||||
self.tools
|
self.tools.read().get(name).cloned()
|
||||||
.read()
|
|
||||||
.get(name)
|
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all registered tools.
|
/// Get all registered tools.
|
||||||
/// Used for concurrent tool execution when we need to look up tools by name.
|
/// Used for concurrent tool execution when we need to look up tools by name.
|
||||||
pub fn get_all(&self) -> Vec<Arc<dyn ToolTrait>> {
|
pub fn get_all(&self) -> Vec<Arc<dyn ToolTrait>> {
|
||||||
self.tools
|
self.tools.read().values().cloned().collect()
|
||||||
.read()
|
|
||||||
.values()
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_definitions(&self) -> Vec<Tool> {
|
pub fn get_definitions(&self) -> Vec<Tool> {
|
||||||
@ -56,18 +49,11 @@ impl ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_tools(&self) -> bool {
|
pub fn has_tools(&self) -> bool {
|
||||||
!self
|
!self.tools.read().is_empty()
|
||||||
.tools
|
|
||||||
.read()
|
|
||||||
.is_empty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tool_names(&self) -> Vec<String> {
|
pub fn tool_names(&self) -> Vec<String> {
|
||||||
self.tools
|
self.tools.read().keys().cloned().collect()
|
||||||
.read()
|
|
||||||
.keys()
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建一个排除指定工具的新 registry 副本
|
/// 创建一个排除指定工具的新 registry 副本
|
||||||
@ -80,9 +66,7 @@ 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
|
*new_registry.tools.write() = filtered;
|
||||||
.tools
|
|
||||||
.write() = filtered;
|
|
||||||
new_registry
|
new_registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,9 +81,7 @@ 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
|
*new_registry.tools.write() = filtered;
|
||||||
.tools
|
|
||||||
.write() = filtered;
|
|
||||||
new_registry
|
new_registry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -338,8 +338,8 @@ fn enrich_target_from_context(
|
|||||||
_ => return target,
|
_ => return target,
|
||||||
};
|
};
|
||||||
|
|
||||||
if !has_non_empty_string(&object, "channel") {
|
if !has_non_empty_string(&object, "channel")
|
||||||
if let Some(channel_name) = context
|
&& let Some(channel_name) = context
|
||||||
.channel_name
|
.channel_name
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
@ -349,10 +349,9 @@ fn enrich_target_from_context(
|
|||||||
serde_json::Value::String(channel_name.clone()),
|
serde_json::Value::String(channel_name.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !has_non_empty_string(&object, "chat_id") {
|
if !has_non_empty_string(&object, "chat_id")
|
||||||
if let Some(chat_id) = context
|
&& let Some(chat_id) = context
|
||||||
.chat_id
|
.chat_id
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
@ -362,7 +361,6 @@ fn enrich_target_from_context(
|
|||||||
serde_json::Value::String(chat_id.clone()),
|
serde_json::Value::String(chat_id.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::Value::Object(object)
|
serde_json::Value::Object(object)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -114,11 +114,12 @@ impl SchemaCleanr {
|
|||||||
anyhow::bail!("Schema missing required 'type' field");
|
anyhow::bail!("Schema missing required 'type' field");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(Value::String(t)) = obj.get("type") {
|
if let Some(Value::String(t)) = obj.get("type")
|
||||||
if t == "object" && !obj.contains_key("properties") {
|
&& t == "object"
|
||||||
|
&& !obj.contains_key("properties")
|
||||||
|
{
|
||||||
tracing::warn!("Object schema without 'properties' field may cause issues");
|
tracing::warn!("Object schema without 'properties' field may cause issues");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -173,11 +174,11 @@ impl SchemaCleanr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle anyOf/oneOf simplification
|
// Handle anyOf/oneOf simplification
|
||||||
if obj.contains_key("anyOf") || obj.contains_key("oneOf") {
|
if (obj.contains_key("anyOf") || obj.contains_key("oneOf"))
|
||||||
if let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack) {
|
&& let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack)
|
||||||
|
{
|
||||||
return simplified;
|
return simplified;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Build cleaned object
|
// Build cleaned object
|
||||||
let mut cleaned = Map::new();
|
let mut cleaned = Map::new();
|
||||||
@ -244,14 +245,14 @@ impl SchemaCleanr {
|
|||||||
return Self::preserve_meta(obj, Value::Object(Map::new()));
|
return Self::preserve_meta(obj, Value::Object(Map::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(def_name) = Self::parse_local_ref(ref_value) {
|
if let Some(def_name) = Self::parse_local_ref(ref_value)
|
||||||
if let Some(definition) = defs.get(def_name.as_str()) {
|
&& let Some(definition) = defs.get(def_name.as_str())
|
||||||
|
{
|
||||||
ref_stack.insert(ref_value.to_string());
|
ref_stack.insert(ref_value.to_string());
|
||||||
let cleaned = Self::clean_with_defs(definition.clone(), defs, strategy, ref_stack);
|
let cleaned = Self::clean_with_defs(definition.clone(), defs, strategy, ref_stack);
|
||||||
ref_stack.remove(ref_value);
|
ref_stack.remove(ref_value);
|
||||||
return Self::preserve_meta(obj, cleaned);
|
return Self::preserve_meta(obj, cleaned);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
tracing::warn!("Cannot resolve $ref: {}", ref_value);
|
tracing::warn!("Cannot resolve $ref: {}", ref_value);
|
||||||
Self::preserve_meta(obj, Value::Object(Map::new()))
|
Self::preserve_meta(obj, Value::Object(Map::new()))
|
||||||
@ -342,17 +343,18 @@ impl SchemaCleanr {
|
|||||||
if let Some(Value::Null) = obj.get("const") {
|
if let Some(Value::Null) = obj.get("const") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if let Some(Value::Array(arr)) = obj.get("enum") {
|
if let Some(Value::Array(arr)) = obj.get("enum")
|
||||||
if arr.len() == 1 && matches!(arr[0], Value::Null) {
|
&& arr.len() == 1
|
||||||
|
&& matches!(arr[0], Value::Null)
|
||||||
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
if let Some(Value::String(t)) = obj.get("type")
|
||||||
if let Some(Value::String(t)) = obj.get("type") {
|
&& t == "null"
|
||||||
if t == "null" {
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -211,11 +211,9 @@ impl Tool for SkillManageTool {
|
|||||||
Err(err) => return Ok(error_result(&err)),
|
Err(err) => return Ok(error_result(&err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if reload {
|
if reload && let Err(err) = self.skills.reload() {
|
||||||
if let Err(err) = self.skills.reload() {
|
|
||||||
return Ok(error_result(&err));
|
return Ok(error_result(&err));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"status": "disabled",
|
"status": "disabled",
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
|
use parking_lot::RwLock;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use parking_lot::RwLock;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@ -535,7 +535,11 @@ impl DefaultSubAgentRuntime {
|
|||||||
let inherited = session
|
let inherited = session
|
||||||
.parent_topic_id
|
.parent_topic_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|tid| self.topic_model_selections.as_ref().and_then(|s| s.get(tid)))
|
.and_then(|tid| {
|
||||||
|
self.topic_model_selections
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.get(tid))
|
||||||
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
self.model_selections
|
self.model_selections
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@ -877,11 +881,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
// 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。
|
// 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。
|
||||||
// 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝,
|
// 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝,
|
||||||
// 防止 LLM 通过选择被禁子代理绕过限制。
|
// 防止 LLM 通过选择被禁子代理绕过限制。
|
||||||
if let Some(cap) = &parent_context.parent_capability {
|
if let Some(cap) = &parent_context.parent_capability
|
||||||
if let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name) {
|
&& let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name)
|
||||||
|
{
|
||||||
return Err(TaskError::InvalidArguments(msg));
|
return Err(TaskError::InvalidArguments(msg));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 查找子代理定义
|
// 3. 查找子代理定义
|
||||||
let def = self
|
let def = self
|
||||||
@ -1097,8 +1101,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
};
|
};
|
||||||
let _ = sub_done_sender.send(result).await;
|
let _ = sub_done_sender.send(result).await;
|
||||||
let _ =
|
let _ = store.update_pending_subagent_status(&task_id_for_spawn, "failed");
|
||||||
store.update_pending_subagent_status(&task_id_for_spawn, "failed");
|
|
||||||
// _registry_guard drop 时清理 registry 条目
|
// _registry_guard drop 时清理 registry 条目
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1140,11 +1143,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
String::new(),
|
String::new(),
|
||||||
"cancelled".to_string(),
|
"cancelled".to_string(),
|
||||||
),
|
),
|
||||||
Err(e) => (
|
Err(e) => (SubagentStatus::Failed, String::new(), e.to_string()),
|
||||||
SubagentStatus::Failed,
|
|
||||||
String::new(),
|
|
||||||
e.to_string(),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 查询同 topic 下仍未完成的子代理列表
|
// 查询同 topic 下仍未完成的子代理列表
|
||||||
@ -1181,7 +1180,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
SubagentStatus::Timeout => "timeout",
|
SubagentStatus::Timeout => "timeout",
|
||||||
SubagentStatus::Cancelled => "cancelled",
|
SubagentStatus::Cancelled => "cancelled",
|
||||||
};
|
};
|
||||||
if let Err(e) = store.update_pending_subagent_status(&task_id_for_spawn, status_str) {
|
if let Err(e) = store.update_pending_subagent_status(&task_id_for_spawn, status_str)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = %e,
|
error = %e,
|
||||||
task_id = %task_id_for_spawn,
|
task_id = %task_id_for_spawn,
|
||||||
@ -1211,7 +1211,13 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
if let Err(e) = task_repository.save_task_session(&session_done).await {
|
if let Err(e) = task_repository.save_task_session(&session_done).await {
|
||||||
tracing::warn!(error = %e, task_id = %task_id_for_spawn, "Failed to save failed session");
|
tracing::warn!(error = %e, task_id = %task_id_for_spawn, "Failed to save failed session");
|
||||||
}
|
}
|
||||||
publish_subagent_error(&bus, &session_done, &e.to_string(), &trace_id_owned).await;
|
publish_subagent_error(
|
||||||
|
&bus,
|
||||||
|
&session_done,
|
||||||
|
&e.to_string(),
|
||||||
|
&trace_id_owned,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// _registry_guard 在此 drop,确定性清理 cancel_registry 条目
|
// _registry_guard 在此 drop,确定性清理 cancel_registry 条目
|
||||||
@ -1236,7 +1242,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
|
|
||||||
// ===== 同步路径(子代理嵌套或无 sub_done_sender) =====
|
// ===== 同步路径(子代理嵌套或无 sub_done_sender) =====
|
||||||
// 9. 执行任务并处理结果
|
// 9. 执行任务并处理结果
|
||||||
let result = self.execute_task(agent, &session, &def, task.prompt.clone()).await;
|
let result = self
|
||||||
|
.execute_task(agent, &session, &def, task.prompt.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(tool_result) => {
|
Ok(tool_result) => {
|
||||||
@ -1303,11 +1311,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
// 4.1 校验父智能体的子代理策略(白/黑名单)。
|
// 4.1 校验父智能体的子代理策略(白/黑名单)。
|
||||||
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
|
// 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许
|
||||||
// 该子代理的专家,resume 应失败(与 def 被删除即失败的安全语义一致)。
|
// 该子代理的专家,resume 应失败(与 def 被删除即失败的安全语义一致)。
|
||||||
if let Some(cap) = &parent_context.parent_capability {
|
if let Some(cap) = &parent_context.parent_capability
|
||||||
if let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) {
|
&& let Err(msg) = cap.check_subagent_allowed(&session.subagent_type)
|
||||||
|
{
|
||||||
return Err(TaskError::InvalidArguments(msg));
|
return Err(TaskError::InvalidArguments(msg));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 4.2 重新解析 def 以应用工具过滤。
|
// 4.2 重新解析 def 以应用工具过滤。
|
||||||
// 安全要求:def 被删除/禁用时必须失败恢复,而不是降级为完整工具集——
|
// 安全要求:def 被删除/禁用时必须失败恢复,而不是降级为完整工具集——
|
||||||
@ -1406,10 +1414,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
|||||||
// token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失)
|
// token 不在 registry 中(可能已完成但 DB 状态未更新,或进程重启后丢失)
|
||||||
// 不变量 1:条件 UPDATE,仅在 status='running' 时转为 cancelled,
|
// 不变量 1:条件 UPDATE,仅在 status='running' 时转为 cancelled,
|
||||||
// 避免 spawn 已完成的终态被覆盖(completed → cancelled 是非法转换)
|
// 避免 spawn 已完成的终态被覆盖(completed → cancelled 是非法转换)
|
||||||
match self
|
match self.store.try_update_pending_subagent_status(
|
||||||
.store
|
&record.task_id,
|
||||||
.try_update_pending_subagent_status(&record.task_id, "running", "cancelled")
|
"running",
|
||||||
{
|
"cancelled",
|
||||||
|
) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
task_id = %record.task_id,
|
task_id = %record.task_id,
|
||||||
@ -1753,24 +1762,15 @@ impl SubagentRuntime {
|
|||||||
/// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。
|
/// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。
|
||||||
pub fn reload(&self) -> Result<(), String> {
|
pub fn reload(&self) -> Result<(), String> {
|
||||||
let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd);
|
let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd);
|
||||||
let mut guard = self
|
let mut guard = self.catalog.write();
|
||||||
.catalog
|
|
||||||
.write()
|
|
||||||
;
|
|
||||||
*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
|
let state = self.disable_state.read();
|
||||||
.disable_state
|
let catalog = self.catalog.read();
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let catalog = self
|
|
||||||
.catalog
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let mut items: Vec<SubagentWithStatus> = catalog
|
let mut items: Vec<SubagentWithStatus> = catalog
|
||||||
.all()
|
.all()
|
||||||
.iter()
|
.iter()
|
||||||
@ -1794,14 +1794,8 @@ impl SubagentRuntime {
|
|||||||
|
|
||||||
/// 可用子代理名称(过滤禁用项)
|
/// 可用子代理名称(过滤禁用项)
|
||||||
pub fn available_names(&self) -> Vec<String> {
|
pub fn available_names(&self) -> Vec<String> {
|
||||||
let state = self
|
let state = self.disable_state.read();
|
||||||
.disable_state
|
let catalog = self.catalog.read();
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let catalog = self
|
|
||||||
.catalog
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
catalog
|
catalog
|
||||||
.names()
|
.names()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -1811,30 +1805,17 @@ impl SubagentRuntime {
|
|||||||
|
|
||||||
/// 查找可用子代理(过滤禁用项)
|
/// 查找可用子代理(过滤禁用项)
|
||||||
pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
|
pub fn find_available(&self, name: &str) -> Option<SubagentDef> {
|
||||||
let state = self
|
let state = self.disable_state.read();
|
||||||
.disable_state
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
if state.is_disabled(name) {
|
if state.is_disabled(name) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.catalog
|
self.catalog.read().find(name).cloned()
|
||||||
.read()
|
|
||||||
|
|
||||||
.find(name)
|
|
||||||
.cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成过滤后的系统索引提示词
|
/// 生成过滤后的系统索引提示词
|
||||||
pub fn system_index_prompt_filtered(&self) -> Option<String> {
|
pub fn system_index_prompt_filtered(&self) -> Option<String> {
|
||||||
let state = self
|
let state = self.disable_state.read();
|
||||||
.disable_state
|
let catalog = self.catalog.read();
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let catalog = self
|
|
||||||
.catalog
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let available_defs: Vec<&SubagentDef> = catalog
|
let available_defs: Vec<&SubagentDef> = catalog
|
||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -1872,14 +1853,8 @@ impl SubagentRuntime {
|
|||||||
allowed: Option<&[String]>,
|
allowed: Option<&[String]>,
|
||||||
denied: &[String],
|
denied: &[String],
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let state = self
|
let state = self.disable_state.read();
|
||||||
.disable_state
|
let catalog = self.catalog.read();
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let catalog = self
|
|
||||||
.catalog
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let available_defs: Vec<&SubagentDef> = catalog
|
let available_defs: Vec<&SubagentDef> = catalog
|
||||||
.all()
|
.all()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@ -1942,13 +1917,7 @@ impl SubagentRuntime {
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<SubagentAvailabilityChange, String> {
|
) -> Result<SubagentAvailabilityChange, String> {
|
||||||
// 校验子代理存在
|
// 校验子代理存在
|
||||||
if self
|
if self.catalog.read().find(name).is_none() {
|
||||||
.catalog
|
|
||||||
.read()
|
|
||||||
|
|
||||||
.find(name)
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
return Err(format!("subagent '{}' not found", name));
|
return Err(format!("subagent '{}' not found", name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1969,10 +1938,7 @@ impl SubagentRuntime {
|
|||||||
|
|
||||||
// 更新内存中的 disable_state
|
// 更新内存中的 disable_state
|
||||||
{
|
{
|
||||||
let mut state = self
|
let mut state = self.disable_state.write();
|
||||||
.disable_state
|
|
||||||
.write()
|
|
||||||
;
|
|
||||||
match scope {
|
match scope {
|
||||||
SubagentScope::User => {
|
SubagentScope::User => {
|
||||||
if enabled {
|
if enabled {
|
||||||
@ -1992,10 +1958,7 @@ impl SubagentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 计算新的 disabled_in_scopes
|
// 计算新的 disabled_in_scopes
|
||||||
let state = self
|
let state = self.disable_state.read();
|
||||||
.disable_state
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let disabled_in_scopes = state.disabled_scopes_for(name);
|
let disabled_in_scopes = state.disabled_scopes_for(name);
|
||||||
|
|
||||||
Ok(SubagentAvailabilityChange {
|
Ok(SubagentAvailabilityChange {
|
||||||
@ -2023,10 +1986,7 @@ impl SubagentRuntime {
|
|||||||
reload: bool,
|
reload: bool,
|
||||||
) -> Result<SubagentDef, String> {
|
) -> Result<SubagentDef, String> {
|
||||||
let def = {
|
let def = {
|
||||||
let catalog = self
|
let catalog = self.catalog.read();
|
||||||
.catalog
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
catalog
|
catalog
|
||||||
.find(name)
|
.find(name)
|
||||||
.ok_or_else(|| format!("subagent '{}' not found", name))?
|
.ok_or_else(|| format!("subagent '{}' not found", name))?
|
||||||
@ -2089,10 +2049,7 @@ impl SubagentRuntime {
|
|||||||
) -> Result<SubagentDef, String> {
|
) -> Result<SubagentDef, String> {
|
||||||
validate_subagent_name(name)?;
|
validate_subagent_name(name)?;
|
||||||
{
|
{
|
||||||
let catalog = self
|
let catalog = self.catalog.read();
|
||||||
.catalog
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
if catalog.find(name).is_some() {
|
if catalog.find(name).is_some() {
|
||||||
return Err(format!("subagent '{}' already exists", name));
|
return Err(format!("subagent '{}' already exists", name));
|
||||||
}
|
}
|
||||||
@ -2136,17 +2093,10 @@ impl SubagentRuntime {
|
|||||||
/// 对齐 `ExpertRuntime::delete_expert`。
|
/// 对齐 `ExpertRuntime::delete_expert`。
|
||||||
/// - builtin 子代理(path 为 None)禁止删除。
|
/// - builtin 子代理(path 为 None)禁止删除。
|
||||||
/// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。
|
/// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。
|
||||||
pub fn delete_subagent(
|
pub fn delete_subagent(&self, name: &str, reload: bool) -> Result<PathBuf, String> {
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
reload: bool,
|
|
||||||
) -> Result<PathBuf, String> {
|
|
||||||
validate_subagent_name(name)?;
|
validate_subagent_name(name)?;
|
||||||
let path = {
|
let path = {
|
||||||
let catalog = self
|
let catalog = self.catalog.read();
|
||||||
.catalog
|
|
||||||
.read()
|
|
||||||
;
|
|
||||||
let def = catalog
|
let def = catalog
|
||||||
.find(name)
|
.find(name)
|
||||||
.ok_or_else(|| format!("subagent '{}' not found", name))?;
|
.ok_or_else(|| format!("subagent '{}' not found", name))?;
|
||||||
@ -2201,11 +2151,7 @@ fn validate_subagent_name(name: &str) -> Result<(), String> {
|
|||||||
|
|
||||||
/// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。
|
/// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。
|
||||||
/// 对齐 `expert_file_path`。
|
/// 对齐 `expert_file_path`。
|
||||||
fn subagent_file_path(
|
fn subagent_file_path(scope: SubagentScope, name: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||||||
scope: SubagentScope,
|
|
||||||
name: &str,
|
|
||||||
cwd: &Path,
|
|
||||||
) -> Result<PathBuf, String> {
|
|
||||||
let root = match scope {
|
let root = match scope {
|
||||||
SubagentScope::User => dirs::home_dir()
|
SubagentScope::User => dirs::home_dir()
|
||||||
.map(|p| p.join(".picobot").join("subagents"))
|
.map(|p| p.join(".picobot").join("subagents"))
|
||||||
@ -2632,7 +2578,7 @@ mod tests {
|
|||||||
|
|
||||||
// 禁用后 prompt 不应包含 general(无可用子代理时返回 None)
|
// 禁用后 prompt 不应包含 general(无可用子代理时返回 None)
|
||||||
let prompt = runtime.system_index_prompt_filtered();
|
let prompt = runtime.system_index_prompt_filtered();
|
||||||
assert!(prompt.map_or(true, |p| !p.contains("<name>general</name>")));
|
assert!(prompt.is_none_or(|p| !p.contains("<name>general</name>")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -3142,10 +3088,7 @@ mod tests {
|
|||||||
let item = items.iter().find(|i| i.name == "demo-create").unwrap();
|
let item = items.iter().find(|i| i.name == "demo-create").unwrap();
|
||||||
assert_eq!(item.description, "demo create agent");
|
assert_eq!(item.description, "demo create agent");
|
||||||
assert_eq!(item.body.as_deref(), Some("demo body content"));
|
assert_eq!(item.body.as_deref(), Some("demo body content"));
|
||||||
assert_eq!(
|
assert_eq!(item.capability.denied_skills, vec!["skill_x".to_string()]);
|
||||||
item.capability.denied_skills,
|
|
||||||
vec!["skill_x".to_string()]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -3288,7 +3231,8 @@ mod tests {
|
|||||||
"directory should be preserved when it has other files"
|
"directory should be preserved when it has other files"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!temp.path()
|
!temp
|
||||||
|
.path()
|
||||||
.join(".picobot")
|
.join(".picobot")
|
||||||
.join("subagents")
|
.join("subagents")
|
||||||
.join("mixed")
|
.join("mixed")
|
||||||
|
|||||||
@ -103,7 +103,7 @@ impl Tool for TaskTool {
|
|||||||
|
|
||||||
// 2. 验证描述长度
|
// 2. 验证描述长度
|
||||||
let word_count = task_args.description.split_whitespace().count();
|
let word_count = task_args.description.split_whitespace().count();
|
||||||
if task_args.description.len() > 50 || word_count > 7 || word_count < 1 {
|
if task_args.description.len() > 50 || !(1..=7).contains(&word_count) {
|
||||||
return Ok(ToolResult {
|
return Ok(ToolResult {
|
||||||
success: false,
|
success: false,
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
@ -136,8 +136,9 @@ impl Tool for TaskTool {
|
|||||||
|
|
||||||
// 4. 深度校验(仅对嵌套场景生效,None = 不限制)
|
// 4. 深度校验(仅对嵌套场景生效,None = 不限制)
|
||||||
// Some(N) 表示允许最多 N 层嵌套:depth=1 的 agent 可创建 depth=2,但 depth=2 不能再创建
|
// Some(N) 表示允许最多 N 层嵌套:depth=1 的 agent 可创建 depth=2,但 depth=2 不能再创建
|
||||||
if let Some(max_depth) = self.max_nesting_depth {
|
if let Some(max_depth) = self.max_nesting_depth
|
||||||
if context.nesting_depth > max_depth {
|
&& context.nesting_depth > max_depth
|
||||||
|
{
|
||||||
return Ok(ToolResult {
|
return Ok(ToolResult {
|
||||||
success: false,
|
success: false,
|
||||||
output: String::new(),
|
output: String::new(),
|
||||||
@ -147,7 +148,6 @@ impl Tool for TaskTool {
|
|||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 执行任务
|
// 5. 执行任务
|
||||||
let result = if let Some(task_id) = task_args.task_id {
|
let result = if let Some(task_id) = task_args.task_id {
|
||||||
|
|||||||
@ -8,8 +8,10 @@ use crate::utils::current_timestamp;
|
|||||||
/// 子代理会话状态
|
/// 子代理会话状态
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
#[derive(Default)]
|
||||||
pub enum TaskSessionState {
|
pub enum TaskSessionState {
|
||||||
/// 正在执行
|
/// 正在执行
|
||||||
|
#[default]
|
||||||
Running,
|
Running,
|
||||||
/// 已完成
|
/// 已完成
|
||||||
Completed,
|
Completed,
|
||||||
@ -23,12 +25,6 @@ pub enum TaskSessionState {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TaskSessionState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TaskSessionState {
|
impl TaskSessionState {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@ -88,12 +88,12 @@ impl Tool for TodoReadTool {
|
|||||||
// 2. 读锁查内存
|
// 2. 读锁查内存
|
||||||
{
|
{
|
||||||
let guard = self.state.read().await;
|
let guard = self.state.read().await;
|
||||||
if let Some(items) = guard.get(&scope_key) {
|
if let Some(items) = guard.get(&scope_key)
|
||||||
if !items.is_empty() {
|
&& !items.is_empty()
|
||||||
|
{
|
||||||
return Ok(success_result(items, &scope_key, "memory"));
|
return Ok(success_result(items, &scope_key, "memory"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 内存为空 → 查 SQLite 并回填
|
// 3. 内存为空 → 查 SQLite 并回填
|
||||||
let records = match self.repository.list_todos(&scope_key) {
|
let records = match self.repository.list_todos(&scope_key) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use std::time::Duration;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
@ -58,11 +58,7 @@ pub trait WaitCoordinator: Send + Sync + 'static {
|
|||||||
/// select! 立即返回 `WaitEvent::Cancelled`,并完成完整的状态清理
|
/// select! 立即返回 `WaitEvent::Cancelled`,并完成完整的状态清理
|
||||||
///(重获取锁、回填 guard、清除 is_waiting、归还 receiver)。
|
///(重获取锁、回填 guard、清除 is_waiting、归还 receiver)。
|
||||||
/// 为 None 时退化为不检查取消(向后兼容,子代理场景)。
|
/// 为 None 时退化为不检查取消(向后兼容,子代理场景)。
|
||||||
async fn wait(
|
async fn wait(&self, timeout: Duration, cancel_rx: Option<watch::Receiver<()>>) -> WaitEvent;
|
||||||
&self,
|
|
||||||
timeout: Duration,
|
|
||||||
cancel_rx: Option<watch::Receiver<()>>,
|
|
||||||
) -> WaitEvent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
|
|||||||
@ -156,9 +156,7 @@ impl Tool for WaitForSubagentsTool {
|
|||||||
// 传入 cancel_rx 使 /stop 命令能立即中断等待。
|
// 传入 cancel_rx 使 /stop 命令能立即中断等待。
|
||||||
// coordinator 在 select! 中以 biased 优先级处理:
|
// coordinator 在 select! 中以 biased 优先级处理:
|
||||||
// 子代理结果 > 用户消息 > 取消信号 > 超时
|
// 子代理结果 > 用户消息 > 取消信号 > 超时
|
||||||
let event = coordinator
|
let event = coordinator.wait(timeout, context.cancel_rx.clone()).await;
|
||||||
.wait(timeout, context.cancel_rx.clone())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 5. 格式化返回结果
|
// 5. 格式化返回结果
|
||||||
let output = match event {
|
let output = match event {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ fn test_message_special_characters() {
|
|||||||
/// Test that multi-line system prompt is preserved
|
/// Test that multi-line system prompt is preserved
|
||||||
#[test]
|
#[test]
|
||||||
fn test_multiline_system_prompt() {
|
fn test_multiline_system_prompt() {
|
||||||
let messages = vec![
|
let messages = [
|
||||||
Message::system(
|
Message::system(
|
||||||
"You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate",
|
"You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate",
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user