mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.9.0
This commit is contained in:
@@ -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 集成
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
) {
|
||||
|
||||
@@ -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 时会直接报重名。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user