release: v1.9.0

This commit is contained in:
coso
2026-04-13 02:50:53 +08:00
parent 52624b1488
commit 0f15b8a71e
313 changed files with 19875 additions and 9864 deletions
+1 -1
View File
@@ -12,6 +12,7 @@ AI Agent 集成模块,基于 aster-rust 框架实现。
- **凭证池桥接**:自动从 Lime 凭证池选择凭证配置 Aster Provider
- **流式响应**:通过 Tauri 事件系统向前端推送流式内容
- **Skills 集成**:自动加载 Lime Skills 到 aster-rust,使 AI 能够自动调用
- **多代理收敛**:旧 SubAgent scheduler Tauri 桥已删除,角色与 team runtime 纯逻辑统一收敛到 `crates/agent/src/subagent_scheduler.rs`
## 文件索引
@@ -24,7 +25,6 @@ AI Agent 集成模块,基于 aster-rust 框架实现。
| `event_converter.rs` | Aster 事件到 Tauri 事件转换 |
| `credential_bridge.rs` | 重导出层(纯逻辑已迁移到 `crates/agent/src/credential_bridge.rs`) |
| `mcp_bridge.rs` | MCP 服务桥接 |
| `subagent_scheduler.rs` | Tauri 事件桥接层(纯逻辑已迁移到 `crates/agent/src/subagent_scheduler.rs`) |
## Skills 集成
-4
View File
@@ -7,7 +7,6 @@ mod aster_agent;
pub mod aster_state;
mod credential_bridge;
pub mod runtime_queue_service;
mod subagent_scheduler;
// 从 lime-agent crate re-export
pub use lime_agent::event_converter;
@@ -28,6 +27,3 @@ pub use lime_agent::{
QueuedTurnSnapshot, QueuedTurnTask, SubagentControlState, SubagentParentContext,
SubagentRuntimeStatus, SubagentRuntimeStatusKind,
};
pub use subagent_scheduler::{
LimeScheduler, LimeSubAgentExecutor, SubAgentProgressEvent, SubAgentRole,
};
-156
View File
@@ -1,156 +0,0 @@
//! SubAgent 调度器集成(Tauri 桥接层)
//!
//! 纯逻辑已迁移到 `lime-agent` crate,
//! 本模块负责 Tauri 事件桥接。
use std::sync::Arc;
use aster::agents::context::AgentContext;
use aster::agents::subagent_scheduler::{
SchedulerConfig, SchedulerExecutionResult, SchedulerResult, SubAgentTask,
};
use tauri::{AppHandle, Emitter};
use crate::database::DbConnection;
pub use lime_agent::subagent_scheduler::{
LimeSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent, SubAgentRole,
};
/// Lime SubAgent 调度器(Tauri 桥接)
pub struct LimeScheduler {
/// 内部纯逻辑调度器
inner: lime_agent::subagent_scheduler::LimeScheduler,
/// Tauri AppHandle
app_handle: Option<AppHandle>,
/// 调度事件归属的会话 ID
event_session_id: Option<String>,
}
impl LimeScheduler {
/// 创建新的调度器
pub fn new(db: DbConnection) -> Self {
Self {
inner: lime_agent::subagent_scheduler::LimeScheduler::new(db),
app_handle: None,
event_session_id: None,
}
}
/// 设置 Tauri AppHandle
pub fn with_app_handle(mut self, handle: AppHandle) -> Self {
self.app_handle = Some(handle);
self
}
/// 绑定调度事件的会话 ID
pub fn with_event_session_id(mut self, session_id: impl Into<String>) -> Self {
let normalized = session_id.into();
self.event_session_id = (!normalized.trim().is_empty()).then_some(normalized);
self
}
/// 设置默认角色
pub fn with_default_role(mut self, role: SubAgentRole) -> Self {
self.inner = self.inner.with_default_role(role);
self
}
/// 初始化调度器
pub async fn init(&self, config: Option<SchedulerConfig>) {
let event_session_id = self.event_session_id.clone();
let event_emitter = self.app_handle.clone().map(|handle| {
Arc::new(move |event: &serde_json::Value| {
let payload = enrich_scheduler_event_payload(event, event_session_id.as_deref());
if let Err(err) = handle.emit("subagent-scheduler-event", payload) {
tracing::warn!("发送 Tauri 事件失败: {}", err);
}
}) as SchedulerEventEmitter
});
self.inner
.init_with_event_emitter(config, event_emitter)
.await;
}
/// 执行任务
pub async fn execute(
&self,
tasks: Vec<SubAgentTask>,
parent_context: Option<&AgentContext>,
) -> SchedulerResult<SchedulerExecutionResult> {
self.inner.execute(tasks, parent_context).await
}
/// 使用指定角色执行任务
pub async fn execute_with_role(
&self,
tasks: Vec<SubAgentTask>,
parent_context: Option<&AgentContext>,
role: SubAgentRole,
) -> SchedulerResult<SchedulerExecutionResult> {
self.inner
.execute_with_role(tasks, parent_context, role)
.await
}
/// 取消执行
pub async fn cancel(&self) {
self.inner.cancel().await;
}
}
fn enrich_scheduler_event_payload(
event: &serde_json::Value,
session_id: Option<&str>,
) -> serde_json::Value {
let Some(session_id) = session_id.map(str::trim).filter(|value| !value.is_empty()) else {
return event.clone();
};
match event {
serde_json::Value::Object(map) => {
let mut next = map.clone();
next.insert(
"sessionId".to_string(),
serde_json::Value::String(session_id.to_string()),
);
serde_json::Value::Object(next)
}
other => serde_json::json!({
"type": "unknown",
"payload": other,
"sessionId": session_id,
}),
}
}
#[cfg(test)]
mod tests {
use super::enrich_scheduler_event_payload;
#[test]
fn should_append_session_id_for_object_event() {
let payload = serde_json::json!({
"type": "started",
"totalTasks": 1,
});
let enriched = enrich_scheduler_event_payload(&payload, Some("session-a"));
assert_eq!(enriched["type"], serde_json::json!("started"));
assert_eq!(enriched["sessionId"], serde_json::json!("session-a"));
}
#[test]
fn should_keep_original_event_when_session_id_missing() {
let payload = serde_json::json!({
"type": "completed",
"success": true,
});
let enriched = enrich_scheduler_event_payload(&payload, None);
assert_eq!(enriched, payload);
}
}
+2 -12
View File
@@ -503,15 +503,6 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: "SubAgentTask",
profiles: CORE_PROFILES,
capabilities: DELEGATION_CAP,
lifecycle: ToolLifecycle::Compat,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: false,
},
ToolCatalogEntry {
name: SOCIAL_IMAGE_TOOL_NAME,
profiles: WORKBENCH_PROFILES,
@@ -937,7 +928,6 @@ mod tests {
assert!(names.contains(&"TeamDelete"));
assert!(names.contains(&"Workflow"));
assert!(names.contains(&"WebSearch"));
assert!(!names.contains(&"SubAgentTask"));
assert!(!names.contains(&"Read"));
assert!(!names.contains(&"Bash"));
assert!(!names.contains(&SOCIAL_IMAGE_TOOL_NAME));
@@ -1059,7 +1049,7 @@ mod tests {
.iter()
.filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist))
.count();
assert_eq!(core.len(), 41);
assert_eq!(core.len(), 40);
assert_eq!(
core.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Current)
@@ -1070,7 +1060,7 @@ mod tests {
core.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Compat)
.count(),
1
0
);
assert!(core
.iter()
+2 -2
View File
@@ -845,9 +845,9 @@ mod tests {
],
});
assert_eq!(inventory.counts.catalog_total, 41);
assert_eq!(inventory.counts.catalog_total, 40);
assert_eq!(inventory.counts.catalog_current_total, 40);
assert_eq!(inventory.counts.catalog_compat_total, 1);
assert_eq!(inventory.counts.catalog_compat_total, 0);
assert_eq!(inventory.counts.registry_total, 4);
assert_eq!(inventory.counts.registry_visible_total, 3);
assert_eq!(inventory.counts.registry_catalog_unmapped_total, 1);
-5
View File
@@ -240,7 +240,6 @@ pub fn run() {
.manage(recording_service)
.manage(mcp_manager_state)
.manage(automation_service_state)
.manage(commands::subagent_cmd::SubAgentSchedulerState::default())
.manage(commands::websocket_cmd::WsServiceState::default())
.manage(crate::services::companion_service::CompanionServiceState::default())
.manage(lime_gateway::telegram::TelegramGatewayState::default())
@@ -1535,10 +1534,6 @@ pub fn run() {
commands::terminal_cmd::terminal_close,
commands::terminal_cmd::terminal_list_sessions,
commands::terminal_cmd::terminal_get_session,
// SubAgent commands
commands::subagent_cmd::init_subagent_scheduler,
commands::subagent_cmd::execute_subagent_tasks,
commands::subagent_cmd::cancel_subagent_tasks,
// Connection commands
commands::connection_cmd::connection_list,
commands::connection_cmd::connection_add,
+7 -10
View File
@@ -16,7 +16,7 @@ use crate::agent::runtime_queue_service::{
};
use crate::agent::{
AsterAgentState, AsterAgentWrapper, QueuedTurnSnapshot, QueuedTurnTask, SessionDetail,
SessionInfo, SubAgentRole,
SessionInfo,
};
use crate::agent_tools::catalog::{
browser_runtime_tool_prefix, build_mcp_extension_surface, workbench_tool_names,
@@ -59,7 +59,6 @@ use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_
use crate::workspace::WorkspaceManager;
use crate::LogState;
use aster::agents::extension::ExtensionConfig;
use aster::agents::subagent_scheduler::SubAgentTask;
use aster::agents::{Agent, AgentEvent};
use aster::chrome_mcp::get_chrome_mcp_tools;
use aster::conversation::message::{
@@ -481,18 +480,16 @@ pub(crate) use summary_skill_launch::{
#[allow(unused_imports)]
pub(crate) use tool_runtime::social_generate_cover_image_cmd;
pub(crate) use tool_runtime::{apply_workspace_sandbox_permissions, ImageInput};
#[cfg(test)]
#[allow(unused_imports)]
pub(crate) use tool_runtime::{
build_subagent_task_definition, build_subagent_task_runtime_message,
collect_subagent_task_compat_warnings, extract_runtime_subagent_result_text,
parse_subagent_role, LimeBrowserMcpTool, SocialGenerateCoverImageTool, SubAgentTaskToolInput,
ToolSearchBridgeTool,
};
pub(crate) use tool_runtime::{
ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered,
ensure_runtime_support_tools_registered, ensure_social_image_tool_registered,
};
#[cfg(test)]
#[allow(unused_imports)]
pub(crate) use tool_runtime::{
extract_runtime_subagent_result_text, LimeBrowserMcpTool, SocialGenerateCoverImageTool,
ToolSearchBridgeTool,
};
pub(crate) use transcription_skill_launch::{
append_transcription_skill_launch_session_permissions,
merge_system_prompt_with_transcription_skill_launch,
@@ -3,6 +3,7 @@ use super::*;
use aster::session::TurnContextOverride;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use lime_core::workspace::WorkspaceSettings;
use tauri::Manager;
const ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE: &str = "artifact_document_repaired";
const ARTIFACT_DOCUMENT_FAILED_WARNING_CODE: &str = "artifact_document_failed";
@@ -13,6 +14,11 @@ const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not
const TURN_MEMORY_PREFETCH_PROMPT_MARKER: &str = "【运行时记忆召回】";
const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime";
const LIME_RUNTIME_AUTO_COMPACT_KEY: &str = "auto_compact";
const AUTO_RUNTIME_MEMORY_MIN_USER_CHARS: usize = 12;
const AUTO_RUNTIME_MEMORY_MIN_ASSISTANT_CHARS: usize = 48;
const AUTO_RUNTIME_MEMORY_MIN_TOTAL_CHARS: usize = 160;
const AUTO_RUNTIME_MEMORY_SESSION_MESSAGE_LIMIT: usize = 8;
const AUTO_RUNTIME_MEMORY_SESSION_MIN_MESSAGE_LENGTH: usize = 18;
fn emit_runtime_events(app: &AppHandle, event_name: &str, events: Vec<RuntimeAgentEvent>) {
for event in events {
@@ -47,6 +53,172 @@ fn merge_runtime_memory_prefetch_prompt(
}
}
fn normalize_runtime_memory_capture_text(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn contains_runtime_memory_capture_signal(text: &str) -> bool {
[
"记住",
"偏好",
"喜欢",
"不喜欢",
"习惯",
"以后",
"规则",
"流程",
"workflow",
"prefer",
"always",
"never",
"计划",
"待办",
"todo",
"下一步",
"错误",
"失败",
"报错",
"修复",
"fix",
"bug",
]
.iter()
.any(|keyword| text.contains(keyword))
}
fn should_auto_capture_runtime_memory_turn(user_message: &str, assistant_output: &str) -> bool {
let normalized_user = normalize_runtime_memory_capture_text(user_message);
let normalized_assistant = normalize_runtime_memory_capture_text(assistant_output);
let total_chars = normalized_user.chars().count() + normalized_assistant.chars().count();
if normalized_user.chars().count() >= AUTO_RUNTIME_MEMORY_MIN_USER_CHARS
&& normalized_assistant.chars().count() >= AUTO_RUNTIME_MEMORY_MIN_ASSISTANT_CHARS
&& total_chars >= AUTO_RUNTIME_MEMORY_MIN_TOTAL_CHARS
{
return true;
}
let signal_text = format!(
"{} {}",
normalized_user.to_lowercase(),
normalized_assistant.to_lowercase()
);
contains_runtime_memory_capture_signal(signal_text.as_str())
}
fn spawn_runtime_memory_capture_task(
app: &AppHandle,
db: &DbConnection,
memory_config: lime_core::config::MemoryConfig,
session_id: &str,
user_message: &str,
assistant_output: &str,
) {
if !memory_config.enabled || !memory_config.auto.enabled {
return;
}
if !should_auto_capture_runtime_memory_turn(user_message, assistant_output) {
return;
}
let context_memory_service = app
.state::<crate::commands::context_memory::ContextMemoryServiceState>()
.inner()
.0
.clone();
let db = db.clone();
let session_id = session_id.to_string();
// 自动沉淀走后台任务,避免延长主回合完成时间。
tokio::spawn(async move {
let candidates = {
let conn = match db.lock() {
Ok(guard) => guard,
Err(error) => {
tracing::warn!(
"[AsterAgent] 后台自动记忆无法获取数据库锁: session_id={}, error={}",
session_id,
error
);
return;
}
};
match crate::services::chat_history_service::load_session_memory_source_candidates(
&conn,
&session_id,
AUTO_RUNTIME_MEMORY_SESSION_MESSAGE_LIMIT,
AUTO_RUNTIME_MEMORY_SESSION_MIN_MESSAGE_LENGTH,
) {
Ok(candidates) => candidates,
Err(error) => {
tracing::warn!(
"[AsterAgent] 后台自动记忆读取候选失败: session_id={}, error={}",
session_id,
error
);
return;
}
}
};
if candidates.is_empty() {
return;
}
match crate::commands::memory_management_cmd::analyze_memory_candidates(
context_memory_service.as_ref(),
&memory_config,
&candidates,
) {
Ok(result) => {
if result.generated_entries > 0 {
tracing::info!(
"[AsterAgent] 已自动沉淀工作记忆: session_id={}, generated={}, dedup={}",
session_id,
result.generated_entries,
result.deduplicated_entries
);
}
}
Err(error) => {
tracing::warn!(
"[AsterAgent] 后台自动沉淀工作记忆失败: session_id={}, error={}",
session_id,
error
);
}
}
match crate::commands::unified_memory_cmd::analyze_unified_memory_candidates(
&db,
&memory_config,
&candidates,
)
.await
{
Ok(result) => {
if result.generated_entries > 0 {
tracing::info!(
"[AsterAgent] 已自动沉淀长期记忆: session_id={}, generated={}, dedup={}",
session_id,
result.generated_entries,
result.deduplicated_entries
);
}
}
Err(error) => {
tracing::warn!(
"[AsterAgent] 后台自动沉淀长期记忆失败: session_id={}, error={}",
session_id,
error
);
}
}
});
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProviderConfigApplyMode {
Direct,
@@ -1222,6 +1394,7 @@ async fn execute_aster_chat_request(
.as_ref()
.map(|config| config.provider_continuation_capability())
.unwrap_or(ProviderContinuationCapability::HistoryReplayOnly);
let runtime_memory_config = runtime_config.memory.clone();
let configured_provider_continuation_state = effective_provider_config
.as_ref()
.map(|config| config.provider_continuation_state())
@@ -1597,6 +1770,14 @@ async fn execute_aster_chat_request(
request_metadata.as_ref(),
execution.text_output.as_str(),
);
spawn_runtime_memory_capture_task(
&app,
db,
runtime_memory_config.clone(),
session_id,
&request.message,
execution.text_output.as_str(),
);
Ok(())
}
Err(primary_error)
@@ -1688,6 +1869,14 @@ async fn execute_aster_chat_request(
request_metadata.as_ref(),
execution.text_output.as_str(),
);
spawn_runtime_memory_capture_task(
&app,
db,
runtime_memory_config.clone(),
session_id,
&request.message,
execution.text_output.as_str(),
);
})
.map_err(|fallback_err| fallback_err.message)
}
@@ -1763,8 +1952,11 @@ async fn execute_aster_chat_request(
if let Ok(events) = terminal_events {
emit_runtime_events(app, &request.event_name, events);
}
let usage = resolve_runtime_message_usage(session_id).await;
if let Some(ref usage) = usage {
let done_event = resolve_runtime_final_done_event(session_id).await;
if let RuntimeAgentEvent::FinalDone {
usage: Some(ref usage),
} = done_event
{
if let Err(error) = persist_latest_assistant_message_usage(db, session_id, usage) {
tracing::warn!(
"[AsterAgent] 持久化消息 usage 失败(已降级继续): {}",
@@ -1772,7 +1964,6 @@ async fn execute_aster_chat_request(
);
}
}
let done_event = RuntimeAgentEvent::FinalDone { usage };
if let Err(e) = app.emit(&request.event_name, &done_event) {
tracing::error!("[AsterAgent] 发送完成事件失败: {}", e);
}
@@ -1854,6 +2045,10 @@ fn resolve_runtime_message_usage_from_session(
Some(lime_agent::AgentTokenUsage {
input_tokens: input_tokens as u32,
output_tokens: output_tokens as u32,
cached_input_tokens: session
.cached_input_tokens
.filter(|value| *value >= 0)
.map(|value| value as u32),
})
}
_ => None,
@@ -1867,6 +2062,12 @@ async fn resolve_runtime_message_usage(session_id: &str) -> Option<lime_agent::A
resolve_runtime_message_usage_from_session(&session)
}
async fn resolve_runtime_final_done_event(session_id: &str) -> RuntimeAgentEvent {
RuntimeAgentEvent::FinalDone {
usage: resolve_runtime_message_usage(session_id).await,
}
}
fn persist_latest_assistant_message_usage(
db: &DbConnection,
session_id: &str,
@@ -1880,6 +2081,7 @@ fn persist_latest_assistant_message_usage(
session_id,
usage.input_tokens,
usage.output_tokens,
usage.cached_input_tokens,
)?;
Ok(())
}
@@ -1903,6 +2105,11 @@ fn build_compaction_session_metrics_update(
let accumulated_input = accumulate(session.accumulated_input_tokens, usage.usage.input_tokens);
let accumulated_output =
accumulate(session.accumulated_output_tokens, usage.usage.output_tokens);
let cached_input_tokens = if usage.usage.output_tokens.is_some() {
usage.usage.cached_input_tokens
} else {
Some(0)
};
let current_window_tokens = usage
.usage
@@ -1913,6 +2120,7 @@ fn build_compaction_session_metrics_update(
CompactionSessionMetricsUpdate {
schedule_id,
current_window_tokens,
cached_input_tokens,
accumulated_total_tokens: accumulated_total,
accumulated_input_tokens: accumulated_input,
accumulated_output_tokens: accumulated_output,
@@ -2228,7 +2436,7 @@ async fn compact_runtime_session_with_trigger(
if let Ok(events) = terminal_events {
emit_runtime_events(app, &event_name, events);
}
let done_event = RuntimeAgentEvent::FinalDone { usage: None };
let done_event = resolve_runtime_final_done_event(&session_id).await;
if let Err(error) = app.emit(&event_name, &done_event) {
tracing::error!("[AsterAgent] 发送压缩完成事件失败: {}", error);
}
@@ -2915,6 +3123,39 @@ mod tests {
);
}
#[test]
fn should_auto_capture_runtime_memory_turn_for_long_turn_content() {
let user_message = "请记下这个团队的偏好:所有需求都先回到主线任务,再给出下一步明确行动。";
let assistant_output = "好的,我会把这条协作规则当作后续回合的默认执行约束,并在继续实现前先说明当前主线、当前阶段以及下一刀要推进的内容,同时避免把工作扩散到无关页面或额外配置面。";
assert!(should_auto_capture_runtime_memory_turn(
user_message,
assistant_output
));
}
#[test]
fn should_auto_capture_runtime_memory_turn_for_memory_signal_keywords() {
let user_message = "记住:以后回复先给结论";
let assistant_output = "收到,我以后会先给结论。";
assert!(should_auto_capture_runtime_memory_turn(
user_message,
assistant_output
));
}
#[test]
fn should_not_auto_capture_runtime_memory_turn_for_short_generic_turn() {
let user_message = "你好";
let assistant_output = "收到";
assert!(!should_auto_capture_runtime_memory_turn(
user_message,
assistant_output
));
}
#[test]
fn service_skill_launch_stage_should_preserve_simple_user_message_and_force_site_run_first() {
let user_message = "请帮我使用 GitHub 查一下 AI Agent 项目";
@@ -3240,6 +3481,7 @@ mod tests {
.total_tokens(Some(90))
.input_tokens(Some(60))
.output_tokens(Some(30))
.cached_input_tokens(Some(12))
.accumulated_total_tokens(Some(300))
.accumulated_input_tokens(Some(200))
.accumulated_output_tokens(Some(100))
@@ -3252,7 +3494,7 @@ mod tests {
let usage = ProviderUsage::new(
"gpt-4.1".to_string(),
Usage::new(Some(120), Some(45), Some(165)),
Usage::new(Some(120), Some(45), Some(165)).with_cached_input_tokens(Some(90)),
);
update_compaction_session_metrics(&session_config, &usage)
@@ -3267,6 +3509,7 @@ mod tests {
assert_eq!(updated.total_tokens, Some(45));
assert_eq!(updated.input_tokens, Some(45));
assert_eq!(updated.output_tokens, Some(0));
assert_eq!(updated.cached_input_tokens, Some(90));
assert_eq!(updated.accumulated_total_tokens, Some(465));
assert_eq!(updated.accumulated_input_tokens, Some(320));
assert_eq!(updated.accumulated_output_tokens, Some(145));
@@ -3294,6 +3537,7 @@ mod tests {
.total_tokens(Some(180))
.input_tokens(Some(120))
.output_tokens(Some(60))
.cached_input_tokens(Some(24))
.accumulated_total_tokens(Some(700))
.accumulated_input_tokens(Some(500))
.accumulated_output_tokens(Some(200))
@@ -3318,6 +3562,7 @@ mod tests {
assert_eq!(updated.total_tokens, Some(0));
assert_eq!(updated.input_tokens, Some(0));
assert_eq!(updated.output_tokens, Some(0));
assert_eq!(updated.cached_input_tokens, Some(0));
assert_eq!(updated.accumulated_total_tokens, Some(700));
assert_eq!(updated.accumulated_input_tokens, Some(500));
assert_eq!(updated.accumulated_output_tokens, Some(200));
@@ -3345,6 +3590,7 @@ mod tests {
.total_tokens(Some(20))
.input_tokens(Some(10))
.output_tokens(Some(10))
.cached_input_tokens(Some(6))
.accumulated_total_tokens(Some(200))
.accumulated_input_tokens(Some(120))
.accumulated_output_tokens(Some(80))
@@ -3355,7 +3601,7 @@ mod tests {
let session_config = SessionConfigBuilder::new(&session.id).build();
let usage = ProviderUsage::new(
"gpt-4.1".to_string(),
Usage::new(Some(30), Some(15), Some(45)),
Usage::new(Some(30), Some(15), Some(45)).with_cached_input_tokens(Some(18)),
);
update_compaction_session_metrics(&session_config, &usage)
@@ -3370,6 +3616,7 @@ mod tests {
assert_eq!(updated.total_tokens, Some(15));
assert_eq!(updated.input_tokens, Some(15));
assert_eq!(updated.output_tokens, Some(0));
assert_eq!(updated.cached_input_tokens, Some(18));
assert_eq!(updated.accumulated_total_tokens, Some(245));
assert_eq!(updated.accumulated_input_tokens, Some(150));
assert_eq!(updated.accumulated_output_tokens, Some(95));
@@ -3379,6 +3626,71 @@ mod tests {
.expect("清理测试会话失败");
}
#[tokio::test]
async fn resolve_runtime_final_done_event_should_include_usage_from_session() {
ensure_runtime_turn_test_session_manager().await;
let session = SessionManager::create_session(
PathBuf::from("."),
"final_done usage 测试".to_string(),
SessionType::User,
)
.await
.expect("创建测试会话失败");
SessionManager::update_session(&session.id)
.input_tokens(Some(204))
.output_tokens(Some(88))
.cached_input_tokens(Some(160))
.apply()
.await
.expect("写入 usage 失败");
let event = resolve_runtime_final_done_event(&session.id).await;
match event {
RuntimeAgentEvent::FinalDone { usage } => {
assert_eq!(
usage.map(|value| (
value.input_tokens,
value.output_tokens,
value.cached_input_tokens,
)),
Some((204, 88, Some(160)))
);
}
other => panic!("收到意外事件: {:?}", other),
}
SessionManager::delete_session(&session.id)
.await
.expect("清理测试会话失败");
}
#[tokio::test]
async fn resolve_runtime_final_done_event_should_fall_back_to_none_without_session_usage() {
ensure_runtime_turn_test_session_manager().await;
let session = SessionManager::create_session(
PathBuf::from("."),
"final_done 无 usage 测试".to_string(),
SessionType::User,
)
.await
.expect("创建测试会话失败");
let event = resolve_runtime_final_done_event(&session.id).await;
match event {
RuntimeAgentEvent::FinalDone { usage } => {
assert!(usage.is_none(), "未写入 usage 时应返回 None");
}
other => panic!("收到意外事件: {:?}", other),
}
SessionManager::delete_session(&session.id)
.await
.expect("清理测试会话失败");
}
#[tokio::test]
async fn should_auto_compact_runtime_session_when_workspace_pref_enabled_and_context_threshold_exceeded(
) {
+3 -119
View File
@@ -3851,8 +3851,8 @@ mod tests {
fn test_merge_system_prompt_with_elicitation_context_appends_prompt() {
let metadata = serde_json::json!({
"elicitation_context": {
"source": "legacy_questionnaire",
"mode": "compatibility_bridge",
"source": "runtime_action_required",
"mode": "runtime_metadata",
"entries": [
{
"label": "目标受众",
@@ -3875,7 +3875,7 @@ mod tests {
assert!(merged.contains(ELICITATION_CONTEXT_PROMPT_MARKER));
assert!(merged.contains("目标受众"));
assert!(merged.contains("友好专业"));
assert!(merged.contains("legacy_questionnaire"));
assert!(merged.contains("runtime_action_required"));
}
#[test]
@@ -6262,122 +6262,6 @@ mod tests {
assert!(Arc::ptr_eq(&first, &second));
}
#[test]
fn test_parse_subagent_role_supports_aliases() {
assert_eq!(
parse_subagent_role(Some("explore")).unwrap(),
SubAgentRole::Explorer
);
assert_eq!(
parse_subagent_role(Some("plan")).unwrap(),
SubAgentRole::Planner
);
assert_eq!(
parse_subagent_role(Some("code")).unwrap(),
SubAgentRole::Executor
);
assert_eq!(parse_subagent_role(None).unwrap(), SubAgentRole::Executor);
}
#[test]
fn test_build_subagent_task_definition_uses_role_defaults() {
let input = SubAgentTaskToolInput {
prompt: "分析当前 harness 缺口".to_string(),
task_type: None,
description: None,
role: Some("explorer".to_string()),
timeout_secs: Some(45),
model: None,
return_summary: None,
allowed_tools: None,
denied_tools: None,
max_tokens: None,
};
let task = build_subagent_task_definition(&input, SubAgentRole::Explorer).unwrap();
assert_eq!(task.task_type, "explore");
assert_eq!(task.timeout.map(|value| value.as_secs()), Some(45));
assert!(task.return_summary);
}
#[test]
fn test_build_subagent_task_definition_applies_optional_fields() {
let input = SubAgentTaskToolInput {
prompt: "实现 harness 面板".to_string(),
task_type: Some("code".to_string()),
description: Some("实现前端面板".to_string()),
role: Some("executor".to_string()),
timeout_secs: Some(120),
model: Some("claude-sonnet-4-20250514".to_string()),
return_summary: Some(false),
allowed_tools: Some(vec!["read_file".to_string(), "write_file".to_string()]),
denied_tools: Some(vec!["execute_command".to_string()]),
max_tokens: Some(4096),
};
let task = build_subagent_task_definition(&input, SubAgentRole::Executor).unwrap();
assert_eq!(task.task_type, "code");
assert_eq!(task.description.as_deref(), Some("实现前端面板"));
assert_eq!(task.model.as_deref(), Some("claude-sonnet-4-20250514"));
assert!(!task.return_summary);
assert_eq!(
task.allowed_tools,
Some(vec!["read_file".to_string(), "write_file".to_string()])
);
assert_eq!(task.denied_tools, Some(vec!["execute_command".to_string()]));
assert_eq!(task.max_tokens, Some(4096));
}
#[test]
fn test_build_subagent_task_runtime_message_includes_soft_constraints() {
let input = SubAgentTaskToolInput {
prompt: "探索 team workspace 最佳实践".to_string(),
task_type: Some("explore".to_string()),
description: Some("探索 team workspace".to_string()),
role: Some("explorer".to_string()),
timeout_secs: None,
model: None,
return_summary: None,
allowed_tools: Some(vec!["read_file".to_string()]),
denied_tools: Some(vec!["write_file".to_string()]),
max_tokens: Some(1200),
};
let task = build_subagent_task_definition(&input, SubAgentRole::Explorer).unwrap();
let message = build_subagent_task_runtime_message(&input, &task, SubAgentRole::Explorer);
assert!(message.contains("任务标题:探索 team workspace"));
assert!(message.contains("子代理角色:explorer"));
assert!(message.contains("工具偏好:优先仅使用这些工具:read_file"));
assert!(message.contains("避免使用这些工具:write_file"));
assert!(message.contains("输出控制:请尽量将最终输出控制在 1200 tokens 内。"));
assert!(message.contains("不要再创建新的子代理"));
assert!(message.contains("任务说明:"));
assert!(message.contains("探索 team workspace 最佳实践"));
}
#[test]
fn test_collect_subagent_task_compat_warnings_marks_soft_constraints() {
let input = SubAgentTaskToolInput {
prompt: "探索".to_string(),
task_type: None,
description: None,
role: None,
timeout_secs: None,
model: None,
return_summary: None,
allowed_tools: Some(vec!["read_file".to_string()]),
denied_tools: Some(vec!["write_file".to_string()]),
max_tokens: Some(512),
};
let warnings = collect_subagent_task_compat_warnings(&input);
assert_eq!(warnings.len(), 3);
assert!(warnings.iter().any(|item| item.contains("allowedTools")));
assert!(warnings.iter().any(|item| item.contains("deniedTools")));
assert!(warnings.iter().any(|item| item.contains("maxTokens")));
}
#[test]
fn test_subagent_counts_toward_team_limit_only_counts_active_states() {
assert!(subagent_counts_toward_team_limit(
@@ -43,12 +43,8 @@ pub(crate) use social_tools::ensure_social_image_tool_registered;
pub(crate) use social_tools::social_generate_cover_image_cmd;
#[allow(unused_imports)]
pub(crate) use social_tools::SocialGenerateCoverImageTool;
#[allow(unused_imports)]
pub(crate) use subagent_tools::{
build_subagent_task_definition, build_subagent_task_runtime_message,
collect_subagent_task_compat_warnings, extract_runtime_subagent_result_text,
parse_subagent_role, SubAgentTaskToolInput,
};
#[cfg(test)]
pub(crate) use subagent_tools::extract_runtime_subagent_result_text;
use workspace_tools::WorkspaceSandboxedBashTool;
#[cfg(test)]
pub(crate) use workspace_tools::{
@@ -1,204 +1,6 @@
use super::*;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SubAgentTaskToolInput {
pub(crate) prompt: String,
pub(crate) task_type: Option<String>,
pub(crate) description: Option<String>,
pub(crate) role: Option<String>,
pub(crate) timeout_secs: Option<u64>,
pub(crate) model: Option<String>,
pub(crate) return_summary: Option<bool>,
pub(crate) allowed_tools: Option<Vec<String>>,
pub(crate) denied_tools: Option<Vec<String>>,
pub(crate) max_tokens: Option<usize>,
}
pub(crate) fn parse_subagent_role(raw: Option<&str>) -> Result<SubAgentRole, ToolError> {
let normalized = raw
.map(|value| value.trim().to_ascii_lowercase())
.unwrap_or_else(|| "executor".to_string());
match normalized.as_str() {
"" | "executor" | "execute" | "code" => Ok(SubAgentRole::Executor),
"planner" | "plan" => Ok(SubAgentRole::Planner),
"explorer" | "explore" | "research" => Ok(SubAgentRole::Explorer),
_ => Err(ToolError::invalid_params(format!(
"未知 SubAgent 角色: {},支持 explorer/planner/executor",
normalized
))),
}
}
fn default_subagent_task_type(role: SubAgentRole) -> &'static str {
match role {
SubAgentRole::Explorer => "explore",
SubAgentRole::Planner => "plan",
SubAgentRole::Executor => "code",
}
}
pub(crate) fn build_subagent_task_definition(
input: &SubAgentTaskToolInput,
role: SubAgentRole,
) -> Result<SubAgentTask, ToolError> {
let prompt = input.prompt.trim();
if prompt.is_empty() {
return Err(ToolError::invalid_params(
"SubAgentTask.prompt 不能为空".to_string(),
));
}
let task_type = input
.task_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_subagent_task_type(role));
let mut task = SubAgentTask::new(uuid::Uuid::new_v4().to_string(), task_type, prompt);
if let Some(description) = input
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
task = task.with_description(description.to_string());
}
if let Some(timeout_secs) = input.timeout_secs.filter(|value| *value > 0) {
task = task.with_timeout(Duration::from_secs(timeout_secs));
}
if let Some(model) = input
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
task = task.with_model(model.to_string());
}
if let Some(return_summary) = input.return_summary {
task = task.with_summary(return_summary);
}
if let Some(allowed_tools) = input
.allowed_tools
.as_ref()
.filter(|items| !items.is_empty())
{
task = task.with_allowed_tools(allowed_tools.clone());
}
if let Some(denied_tools) = input
.denied_tools
.as_ref()
.filter(|items| !items.is_empty())
{
task = task.with_denied_tools(denied_tools.clone());
}
if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) {
task = task.with_max_tokens(max_tokens);
}
Ok(task)
}
pub(crate) fn build_subagent_task_runtime_message(
input: &SubAgentTaskToolInput,
task: &SubAgentTask,
role: SubAgentRole,
) -> String {
let mut sections = Vec::new();
if let Some(description) = input
.description
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
sections.push(format!("任务标题:{description}"));
}
sections.push(format!("子代理角色:{role}"));
if let Some(task_type) = input
.task_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
sections.push(format!("任务类型:{task_type}"));
}
if let Some(allowed_tools) = input
.allowed_tools
.as_ref()
.filter(|items| !items.is_empty())
{
sections.push(format!(
"工具偏好:优先仅使用这些工具:{}",
allowed_tools.join(", ")
));
}
if let Some(denied_tools) = input
.denied_tools
.as_ref()
.filter(|items| !items.is_empty())
{
sections.push(format!("避免使用这些工具:{}", denied_tools.join(", ")));
}
if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) {
sections.push(format!(
"输出控制:请尽量将最终输出控制在 {max_tokens} tokens 内。"
));
}
sections.push(
"协作约束:你不是唯一工作线程。请只处理当前明确分配的子任务,不要重复主线程或其他子代理的工作,不要再创建新的子代理。"
.to_string(),
);
sections.push("任务说明:".to_string());
sections.push(task.prompt.clone());
sections.join("\n")
}
pub(crate) fn collect_subagent_task_compat_warnings(input: &SubAgentTaskToolInput) -> Vec<String> {
let mut warnings = Vec::new();
if input
.allowed_tools
.as_ref()
.is_some_and(|items| !items.is_empty())
{
warnings
.push("allowedTools 已降级为对子代理的提示,不再由旧 scheduler 做硬限制".to_string());
}
if input
.denied_tools
.as_ref()
.is_some_and(|items| !items.is_empty())
{
warnings
.push("deniedTools 已降级为对子代理的提示,不再由旧 scheduler 做硬限制".to_string());
}
if input.max_tokens.is_some_and(|value| value > 0) {
warnings.push("maxTokens 已降级为输出提示,当前 team runtime 不做强制截断".to_string());
}
warnings
}
#[cfg(test)]
fn extract_runtime_message_text(message: &AgentMessage) -> Option<String> {
let parts = message
.content
@@ -225,6 +27,7 @@ fn extract_runtime_message_text(message: &AgentMessage) -> Option<String> {
}
}
#[cfg(test)]
pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Option<String> {
detail
.messages
@@ -279,246 +82,6 @@ pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Op
})
}
fn summarize_runtime_subagent_execution(
role: SubAgentRole,
status: &SubagentRuntimeStatus,
detail: Option<&SessionDetail>,
) -> String {
let result_text = detail
.and_then(extract_runtime_subagent_result_text)
.unwrap_or_else(|| "未返回摘要".to_string());
match status.kind {
SubagentRuntimeStatusKind::Completed => {
format!("子代理({role}) 已通过 team runtime 完成任务。\n\n{result_text}")
}
SubagentRuntimeStatusKind::Failed | SubagentRuntimeStatusKind::Aborted => {
format!("子代理({role}) 执行失败。\n\n{result_text}")
}
SubagentRuntimeStatusKind::Closed => {
format!("子代理({role}) 已关闭。\n\n{result_text}")
}
SubagentRuntimeStatusKind::NotFound => {
format!("子代理({role}) 未找到,无法获取结果。")
}
_ => format!(
"子代理({role}) 当前状态为 {:?}。\n\n{result_text}",
status.kind
),
}
}
#[derive(Debug, Clone)]
struct SubAgentTaskTool {
runtime: SubagentControlRuntime,
}
impl SubAgentTaskTool {
fn new(runtime: SubagentControlRuntime) -> Self {
Self { runtime }
}
}
#[async_trait]
impl Tool for SubAgentTaskTool {
fn name(&self) -> &str {
"SubAgentTask"
}
fn description(&self) -> &str {
"兼容入口。仅用于兼容仍输出旧 SubAgentTask schema 的历史提示词或旧技能;内部会退化为串行 team runtime 调用,不适合作为新的多代理并发主路径。新实现优先直接使用 Agent / TeamCreate / TeamDelete / SendMessage / ListPeers。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "子代理要执行的任务说明"
},
"taskType": {
"type": "string",
"description": "任务类型,例如 explore、plan、code、review"
},
"description": {
"type": "string",
"description": "展示给用户的任务标题"
},
"role": {
"type": "string",
"description": "子代理角色:explorer、planner、executor"
},
"timeoutSecs": {
"type": "integer",
"minimum": 1,
"description": "单个子任务超时时间(秒)"
},
"model": {
"type": "string",
"description": "可选模型名"
},
"returnSummary": {
"type": "boolean",
"description": "是否优先返回摘要"
},
"allowedTools": {
"type": "array",
"items": { "type": "string" },
"description": "显式允许的工具列表"
},
"deniedTools": {
"type": "array",
"items": { "type": "string" },
"description": "显式拒绝的工具列表"
},
"maxTokens": {
"type": "integer",
"minimum": 1,
"description": "子代理最大 token 限制"
}
},
"required": ["prompt"],
"additionalProperties": false
})
}
fn options(&self) -> ToolOptions {
ToolOptions::new()
.with_max_retries(0)
.with_base_timeout(Duration::from_secs(900))
.with_dynamic_timeout(false)
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: SubAgentTaskToolInput = serde_json::from_value(params)
.map_err(|err| ToolError::invalid_params(format!("SubAgentTask 参数无效: {err}")))?;
let role = parse_subagent_role(input.role.as_deref())?;
let task = build_subagent_task_definition(&input, role)?;
let task_id = task.id.clone();
let parent_session_id = normalize_required_text(&context.session_id, "session_id")
.map_err(ToolError::invalid_params)?;
let compat_warnings = collect_subagent_task_compat_warnings(&input);
let response = agent_runtime_spawn_subagent_internal(
&self.runtime,
AgentRuntimeSpawnSubagentRequest {
parent_session_id,
message: build_subagent_task_runtime_message(&input, &task, role),
name: None,
team_name: None,
agent_type: Some(role.to_string()),
model: input.model.clone(),
run_in_background: false,
reasoning_effort: None,
fork_context: false,
blueprint_role_id: None,
blueprint_role_label: None,
profile_id: None,
profile_name: None,
role_key: None,
skill_ids: Vec::new(),
skill_directories: Vec::new(),
team_preset_id: None,
theme: None,
system_overlay: None,
output_contract: None,
mode: None,
isolation: None,
cwd: None,
},
)
.await
.map_err(|error| {
ToolError::execution_failed(format!(
"SubAgentTask 已切到 team runtime,但创建子代理失败: {error}"
))
})?;
let timeout_ms = input
.timeout_secs
.unwrap_or(900)
.saturating_mul(1000)
.min(i64::MAX as u64) as i64;
let wait_result = agent_runtime_wait_subagents_internal(
&self.runtime,
AgentRuntimeWaitSubagentsRequest {
ids: vec![response.agent_id.clone()],
timeout_ms: Some(timeout_ms),
},
)
.await
.map_err(|error| {
ToolError::execution_failed(format!(
"SubAgentTask 已创建子代理,但等待结果失败: {error}"
))
})?;
let detail =
AsterAgentWrapper::get_runtime_session_detail(&self.runtime.db, &response.agent_id)
.await
.ok();
let status = wait_result
.status
.get(&response.agent_id)
.cloned()
.unwrap_or(SubagentRuntimeStatus {
session_id: response.agent_id.clone(),
kind: if wait_result.timed_out {
SubagentRuntimeStatusKind::Running
} else {
SubagentRuntimeStatusKind::NotFound
},
latest_turn_id: None,
latest_turn_status: None,
queued_turn_count: 0,
team_phase: None,
team_parallel_budget: None,
team_active_count: None,
team_queued_count: None,
provider_concurrency_group: None,
provider_parallel_budget: None,
queue_reason: None,
retryable_overload: false,
closed: false,
});
let summary = if wait_result.timed_out {
format!(
"子代理({role}) 已创建,但在 {} 秒内未完成。可以继续通过 team workspace 跟踪: {}",
input.timeout_secs.unwrap_or(900),
response.agent_id
)
} else {
summarize_runtime_subagent_execution(role, &status, detail.as_ref())
};
let metadata = serde_json::json!({
"task_id": task_id,
"agent_id": response.agent_id,
"nickname": response.nickname,
"role": role.to_string(),
"status": status,
"timed_out": wait_result.timed_out,
"compat_mode": "SubAgentTask->Agent",
"compat_warnings": compat_warnings,
});
let success = !wait_result.timed_out && status.kind == SubagentRuntimeStatusKind::Completed;
let result = if success {
ToolResult::success(summary)
} else {
ToolResult::error(summary)
};
Ok(result
.with_metadata("subagent", metadata)
.with_metadata("role", serde_json::json!(role.to_string())))
}
}
fn build_agent_control_tool_config(
runtime: SubagentControlRuntime,
) -> aster::tools::AgentControlToolConfig {
@@ -595,7 +158,6 @@ pub(super) fn register_subagent_runtime_tools(
registry: &mut aster::tools::ToolRegistry,
runtime: SubagentControlRuntime,
) {
registry.register(Box::new(SubAgentTaskTool::new(runtime.clone())));
aster::tools::register_agent_control_tools(registry, &build_agent_control_tool_config(runtime));
// 本地联调中的 Aster current surface 会在 Agent::list_tools() 里额外注入一份 `Agent`。
// 如果这里继续保留 registry 侧的同名 `Agent`,provider 在格式化 tools 时会直接报重名。
+81 -75
View File
@@ -26,7 +26,8 @@ use crate::services::runtime_agents_template_service::{
use aster::session::list_summaries;
use chrono::{Local, NaiveDateTime, TimeZone};
use lime_core::app_paths;
use lime_services::context_memory_service::{MemoryEntry, MemoryFileType};
use lime_core::config::MemoryConfig;
use lime_services::context_memory_service::{ContextMemoryService, MemoryEntry, MemoryFileType};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
@@ -330,82 +331,9 @@ async fn memory_runtime_request_analysis_impl(
});
}
let max_generated_per_request = memory_config
.max_entries
.unwrap_or(MAX_GENERATED_PER_REQUEST as u32)
.clamp(1, MAX_GENERATED_PER_REQUEST_CAP as u32)
as usize;
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
let candidates = load_memory_candidates(&conn, from_timestamp, to_timestamp)?;
if candidates.is_empty() {
return Ok(MemoryAnalysisResult {
analyzed_sessions: 0,
analyzed_messages: 0,
generated_entries: 0,
deduplicated_entries: 0,
});
}
let mut analyzed_sessions: HashSet<String> = HashSet::new();
let mut generated_entries = 0u32;
let mut deduplicated_entries = 0u32;
let mut generated_count_per_session: HashMap<String, usize> = HashMap::new();
for candidate in candidates.iter().take(MAX_SOURCE_MESSAGES) {
analyzed_sessions.insert(candidate.session_id.clone());
let counter = generated_count_per_session
.entry(candidate.session_id.clone())
.or_insert(0);
if *counter >= MAX_GENERATED_PER_SESSION {
continue;
}
let fingerprint = build_fingerprint(&candidate.content);
let (title, summary, file_type, category_tag) = build_memory_entry_fields(candidate);
let existing = memory_service
.0
.get_session_memories(&candidate.session_id, Some(file_type))?;
if is_duplicate_memory(&existing, &fingerprint, &summary) {
deduplicated_entries += 1;
continue;
}
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
session_id: candidate.session_id.clone(),
file_type,
title,
content: summary,
tags: vec![
"auto_analysis".to_string(),
category_tag.to_string(),
fingerprint,
],
priority: infer_priority(candidate),
created_at: candidate.created_at,
updated_at: candidate.created_at,
archived: false,
};
memory_service.0.save_memory_entry(&entry)?;
generated_entries += 1;
*counter += 1;
if generated_entries as usize >= max_generated_per_request {
break;
}
}
Ok(MemoryAnalysisResult {
analyzed_sessions: analyzed_sessions.len() as u32,
analyzed_messages: candidates.len() as u32,
generated_entries,
deduplicated_entries,
})
analyze_memory_candidates(memory_service.0.as_ref(), &memory_config, &candidates)
}
/// 从历史对话中抽取 runtime / 上下文记忆条目
@@ -1620,6 +1548,84 @@ fn load_memory_candidates(
)
}
pub(crate) fn analyze_memory_candidates(
memory_service: &ContextMemoryService,
memory_config: &MemoryConfig,
candidates: &[MemorySourceCandidate],
) -> Result<MemoryAnalysisResult, String> {
if !memory_config.enabled || candidates.is_empty() {
return Ok(MemoryAnalysisResult {
analyzed_sessions: 0,
analyzed_messages: 0,
generated_entries: 0,
deduplicated_entries: 0,
});
}
let max_generated_per_request = memory_config
.max_entries
.unwrap_or(MAX_GENERATED_PER_REQUEST as u32)
.clamp(1, MAX_GENERATED_PER_REQUEST_CAP as u32)
as usize;
let mut analyzed_sessions: HashSet<String> = HashSet::new();
let mut generated_entries = 0u32;
let mut deduplicated_entries = 0u32;
let mut generated_count_per_session: HashMap<String, usize> = HashMap::new();
for candidate in candidates.iter().take(MAX_SOURCE_MESSAGES) {
analyzed_sessions.insert(candidate.session_id.clone());
let counter = generated_count_per_session
.entry(candidate.session_id.clone())
.or_insert(0);
if *counter >= MAX_GENERATED_PER_SESSION {
continue;
}
let fingerprint = build_fingerprint(&candidate.content);
let (title, summary, file_type, category_tag) = build_memory_entry_fields(candidate);
let existing =
memory_service.get_session_memories(&candidate.session_id, Some(file_type))?;
if is_duplicate_memory(&existing, &fingerprint, &summary) {
deduplicated_entries += 1;
continue;
}
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
session_id: candidate.session_id.clone(),
file_type,
title,
content: summary,
tags: vec![
"auto_analysis".to_string(),
category_tag.to_string(),
fingerprint,
],
priority: infer_priority(candidate),
created_at: candidate.created_at,
updated_at: candidate.created_at,
archived: false,
};
memory_service.save_memory_entry(&entry)?;
generated_entries += 1;
*counter += 1;
if generated_entries as usize >= max_generated_per_request {
break;
}
}
Ok(MemoryAnalysisResult {
analyzed_sessions: analyzed_sessions.len() as u32,
analyzed_messages: candidates.len() as u32,
generated_entries,
deduplicated_entries,
})
}
fn build_fingerprint(content: &str) -> String {
let normalized = content.to_lowercase();
let compact = normalized
-1
View File
@@ -55,7 +55,6 @@ pub mod site_capability_cmd;
pub mod skill_cmd;
pub mod skill_error;
pub mod skill_exec_cmd;
pub mod subagent_cmd;
pub mod telegram_remote_cmd;
pub mod telemetry_cmd;
pub mod terminal_cmd;
-108
View File
@@ -1,108 +0,0 @@
//! SubAgent 调度器命令
//!
//! 提供 SubAgent 调度功能的 Tauri 命令接口
use std::sync::Arc;
use tauri::{AppHandle, State};
use tokio::sync::RwLock;
use aster::agents::context::AgentContext;
use aster::agents::subagent_scheduler::{SchedulerConfig, SchedulerExecutionResult, SubAgentTask};
use crate::agent::{LimeScheduler, SubAgentRole};
use crate::database::DbConnection;
/// SubAgent 调度器状态
pub struct SubAgentSchedulerState {
#[allow(dead_code)]
scheduler: Arc<RwLock<Option<LimeScheduler>>>,
}
impl SubAgentSchedulerState {
pub fn new() -> Self {
Self {
scheduler: Arc::new(RwLock::new(None)),
}
}
}
impl Default for SubAgentSchedulerState {
fn default() -> Self {
Self::new()
}
}
/// 初始化 SubAgent 调度器
#[allow(dead_code)]
#[tauri::command]
pub async fn init_subagent_scheduler(
app: AppHandle,
db: State<'_, DbConnection>,
state: State<'_, SubAgentSchedulerState>,
config: Option<SchedulerConfig>,
session_id: Option<String>,
) -> Result<(), String> {
let mut scheduler = LimeScheduler::new(db.inner().clone()).with_app_handle(app);
if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) {
scheduler = scheduler.with_event_session_id(session_id);
}
scheduler.init(config).await;
*state.scheduler.write().await = Some(scheduler);
Ok(())
}
/// 执行 SubAgent 任务
#[allow(dead_code)]
#[tauri::command]
pub async fn execute_subagent_tasks(
app: AppHandle,
db: State<'_, DbConnection>,
state: State<'_, SubAgentSchedulerState>,
tasks: Vec<SubAgentTask>,
config: Option<SchedulerConfig>,
role: Option<SubAgentRole>,
session_id: Option<String>,
) -> Result<SchedulerExecutionResult, String> {
let mut scheduler = LimeScheduler::new(db.inner().clone()).with_app_handle(app);
if let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) {
scheduler = scheduler.with_event_session_id(session_id);
}
scheduler.init(config.clone()).await;
*state.scheduler.write().await = Some(scheduler);
let scheduler_guard = state.scheduler.read().await;
let scheduler = scheduler_guard
.as_ref()
.ok_or_else(|| "调度器初始化失败".to_string())?;
// 创建父上下文
let parent_context = AgentContext::new();
// 根据是否指定角色选择执行方式
match role {
Some(role) => scheduler
.execute_with_role(tasks, Some(&parent_context), role)
.await
.map_err(|e| e.to_string()),
None => scheduler
.execute(tasks, Some(&parent_context))
.await
.map_err(|e| e.to_string()),
}
}
/// 取消 SubAgent 任务
#[allow(dead_code)]
#[tauri::command]
pub async fn cancel_subagent_tasks(state: State<'_, SubAgentSchedulerState>) -> Result<(), String> {
let scheduler_guard = state.scheduler.read().await;
if let Some(scheduler) = scheduler_guard.as_ref() {
scheduler.cancel().await;
}
Ok(())
}
+27 -19
View File
@@ -5,6 +5,7 @@
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::services::chat_history_service::{load_memory_source_candidates, MemorySourceCandidate};
use lime_core::config::MemoryConfig;
use lime_memory::extractor::{self, ExtractionContext};
use lime_memory::gatekeeper::ChatMessage;
use lime_memory::{MemoryCategory, MemoryMetadata, MemorySource, MemoryType, UnifiedMemory};
@@ -450,6 +451,19 @@ pub async fn unified_memory_analyze(
}
let memory_config = global_config.config().memory;
let candidates = {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
load_memory_candidates(&conn, from_timestamp, to_timestamp)?
};
analyze_unified_memory_candidates(&db, &memory_config, &candidates).await
}
pub(crate) async fn analyze_unified_memory_candidates(
db: &DbConnection,
memory_config: &MemoryConfig,
candidates: &[MemorySourceCandidate],
) -> Result<MemoryAnalysisResult, String> {
if !memory_config.enabled {
info!("[Unified Memory] 记忆功能已关闭,跳过分析");
return Ok(MemoryAnalysisResult {
@@ -460,26 +474,20 @@ pub async fn unified_memory_analyze(
});
}
if candidates.is_empty() {
return Ok(MemoryAnalysisResult {
analyzed_sessions: 0,
analyzed_messages: 0,
generated_entries: 0,
deduplicated_entries: 0,
});
}
let max_generated_per_request = memory_config
.max_entries
.unwrap_or(MAX_GENERATED_PER_REQUEST as u32)
.clamp(1, MAX_GENERATED_PER_REQUEST_CAP as u32)
as usize;
let candidates = {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
load_memory_candidates(&conn, from_timestamp, to_timestamp)?
};
if candidates.is_empty() {
return Ok(MemoryAnalysisResult {
analyzed_sessions: 0,
analyzed_messages: 0,
generated_entries: 0,
deduplicated_entries: 0,
});
}
let analyzed_sessions = candidates
.iter()
.map(|item| item.session_id.clone())
@@ -493,7 +501,7 @@ pub async fn unified_memory_analyze(
let llm_attempted = llm_api_key.is_some();
if let Some(api_key) = llm_api_key {
match build_pending_from_llm(&db, &candidates, &api_key, max_generated_per_request).await {
match build_pending_from_llm(db, candidates, &api_key, max_generated_per_request).await {
Ok((mut llm_pending, llm_dedup)) => {
deduplicated_entries += llm_dedup;
pending_memories.append(&mut llm_pending);
@@ -506,7 +514,7 @@ pub async fn unified_memory_analyze(
if !llm_attempted || pending_memories.is_empty() {
let (mut fallback_pending, fallback_dedup) =
build_pending_from_rules(&db, &candidates, max_generated_per_request)?;
build_pending_from_rules(db, candidates, max_generated_per_request)?;
deduplicated_entries += fallback_dedup;
pending_memories.append(&mut fallback_pending);
}
@@ -540,7 +548,7 @@ pub async fn unified_memory_analyze(
}
fn build_pending_from_rules(
db: &State<'_, DbConnection>,
db: &DbConnection,
candidates: &[MemorySourceCandidate],
max_generated_per_request: usize,
) -> Result<(Vec<PendingMemory>, u32), String> {
@@ -606,7 +614,7 @@ fn build_pending_from_rules(
}
async fn build_pending_from_llm(
db: &State<'_, DbConnection>,
db: &DbConnection,
candidates: &[MemorySourceCandidate],
api_key: &str,
max_generated_per_request: usize,
+85 -1
View File
@@ -48,6 +48,35 @@ pub fn load_memory_source_candidates(
Ok(candidates)
}
pub fn load_session_memory_source_candidates(
conn: &Connection,
session_id: &str,
limit: usize,
min_message_length: usize,
) -> Result<Vec<MemorySourceCandidate>, String> {
let rows = AgentDao::list_message_text_rows_by_session_id(conn, session_id, limit)
.map_err(|e| format!("读取 session agent_messages 失败: {e}"))?;
let mut candidates = Vec::new();
let mut seen = HashSet::new();
for row in rows {
push_candidate(
&mut candidates,
&mut seen,
row.session_id,
row.role,
row.content,
row.timestamp_ms,
min_message_length,
);
}
candidates.sort_by(|a, b| b.created_at.cmp(&a.created_at));
candidates.truncate(limit);
Ok(candidates)
}
fn load_unified_general_candidates(
conn: &Connection,
from_timestamp: Option<i64>,
@@ -189,7 +218,7 @@ fn format_sqlite_datetime(timestamp_ms: i64) -> String {
#[cfg(test)]
mod tests {
use super::load_memory_source_candidates;
use super::{load_memory_source_candidates, load_session_memory_source_candidates};
use rusqlite::{params, Connection};
fn create_test_schema(conn: &Connection) {
@@ -368,4 +397,59 @@ mod tests {
assert!(session_ids.contains(&"agent-1"));
assert!(!session_ids.contains(&"legacy-only"));
}
#[test]
fn load_session_memory_source_candidates_only_reads_target_session() {
let conn = Connection::open_in_memory().expect("open in memory db");
create_test_schema(&conn);
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params![
"agent-1",
"claude-sonnet-4",
"2026-03-12T10:00:00+08:00",
"2026-03-12T10:00:00+08:00"
],
)
.unwrap();
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params![
"agent-2",
"claude-sonnet-4",
"2026-03-12T10:05:00+08:00",
"2026-03-12T10:05:00+08:00"
],
)
.unwrap();
conn.execute(
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
params![
"agent-1",
"user",
r#"[{"type":"text","text":"请记住我现在更偏好 pnpm 工作流"}]"#,
"2026-03-12T10:00:00+08:00"
],
)
.unwrap();
conn.execute(
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
params![
"agent-2",
"assistant",
r#"[{"type":"text","text":"这条消息不该进入 agent-1 的自动抽取"}]"#,
"2026-03-12T10:05:00+08:00"
],
)
.unwrap();
let candidates = load_session_memory_source_candidates(&conn, "agent-1", 10, 1)
.expect("load session candidates");
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].session_id, "agent-1");
assert!(candidates[0].content.contains("pnpm"));
}
}
@@ -1156,11 +1156,39 @@ mod tests {
}
}
fn write_request_telemetry_fixture(root: &Path) {
let request_logs_dir = root.join("request_logs");
fs::create_dir_all(&request_logs_dir).expect("create request logs dir");
let mut log = lime_infra::telemetry::RequestLog::new(
"req-analysis-1".to_string(),
lime_core::ProviderType::OpenAI,
"gpt-5.4".to_string(),
false,
);
log.session_id = Some("session-1".to_string());
log.thread_id = Some("thread-1".to_string());
log.turn_id = Some("turn-1".to_string());
log.pending_request_id = Some("req-1".to_string());
log.queued_turn_id = Some("queued-1".to_string());
log.mark_success(320, 200);
fs::write(
request_logs_dir.join("requests_2026-03-27.jsonl"),
format!(
"{}\n",
serde_json::to_string(&log).expect("serialize request log")
),
)
.expect("write request log");
}
#[test]
fn should_export_runtime_analysis_handoff_to_workspace() {
let temp_dir = TempDir::new().expect("temp dir");
let detail = build_detail();
let thread_read = build_thread_read();
write_request_telemetry_fixture(temp_dir.path());
let result = export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path())
.expect("export");
@@ -1189,7 +1217,8 @@ mod tests {
assert!(brief.contains("外部分析交接简报"));
assert!(brief.contains("pending request:1"));
assert!(brief.contains("证据关联与可观测覆盖"));
assert!(brief.contains("requestTelemetry (unlinked)"));
assert!(brief.contains("requestTelemetry"));
assert!(!brief.contains("requestTelemetry (unlinked)"));
assert!(brief.contains("/workspace/lime"));
let context = fs::read_to_string(context_path).expect("context");
@@ -1199,6 +1228,7 @@ mod tests {
assert!(context.contains("\"observability\""));
assert!(context.contains("\"correlationKeys\""));
assert!(context.contains("\"gapSignals\""));
assert!(context.contains("\"matchedRequestCount\": 1"));
assert!(context.contains("/workspace/lime"));
assert!(!context.contains(temp_dir.path().to_string_lossy().as_ref()));
}
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,7 @@
use crate::agent::SessionDetail;
use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel;
use crate::services::runtime_evidence_pack_service::{
build_runtime_observability_summary_json, export_runtime_evidence_pack,
RuntimeEvidencePackExportResult,
export_runtime_evidence_pack, RuntimeEvidencePackExportResult,
};
use crate::services::runtime_handoff_artifact_service::{
export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult,
@@ -145,12 +144,7 @@ pub fn export_runtime_replay_case(
let recent_artifacts = collect_recent_artifact_paths(detail);
let pending_requests = collect_pending_request_inputs(detail, thread_read);
let recent_timeline = collect_recent_timeline_items(detail);
let observability_summary = build_runtime_observability_summary_json(
detail,
thread_read,
&recent_artifacts,
&evidence_pack.known_gaps,
);
let observability_summary = read_observability_summary_from_evidence_pack(&evidence_pack)?;
let success_criteria = build_success_criteria(
detail,
thread_read,
@@ -283,6 +277,29 @@ fn write_replay_file(
})
}
fn read_observability_summary_from_evidence_pack(
evidence_pack: &RuntimeEvidencePackExportResult,
) -> Result<Value, String> {
let runtime_path = Path::new(&evidence_pack.pack_absolute_root).join("runtime.json");
let raw = fs::read_to_string(&runtime_path).map_err(|error| {
format!(
"读取 evidence pack runtime.json 失败 {}: {error}",
runtime_path.display()
)
})?;
let payload = serde_json::from_str::<Value>(raw.as_str()).map_err(|error| {
format!(
"解析 evidence pack runtime.json 失败 {}: {error}",
runtime_path.display()
)
})?;
Ok(payload
.pointer("/observabilitySummary")
.cloned()
.unwrap_or(Value::Null))
}
#[allow(clippy::too_many_arguments)]
fn build_input_json(
detail: &SessionDetail,
@@ -1261,6 +1278,7 @@ mod tests {
assert!(input.contains("\"pending_request\""));
assert!(input.contains("\"observability\""));
assert!(input.contains("\"requestTelemetry\""));
assert!(!input.contains("\"artifactValidator\""));
let expected = fs::read_to_string(expected_path).expect("expected");
assert!(expected.contains("不要要求与原始会话完全相同的工具调用顺序"));
@@ -1274,6 +1292,6 @@ mod tests {
assert!(links.contains("\"handoffBundle\""));
assert!(links.contains("\"evidencePack\""));
assert!(links.contains("\"observabilitySummary\""));
assert!(links.contains("\"artifactValidator\""));
assert!(!links.contains("\"artifactValidator\""));
}
}