mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v0.93.0
This commit is contained in:
@@ -4,11 +4,19 @@
|
||||
//! 处理消息发送、事件流转换,并桥接会话存储服务。
|
||||
|
||||
use crate::agent::aster_state::{AsterAgentState, SessionConfigBuilder};
|
||||
use crate::config::GlobalConfigManagerState;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::memory_profile_prompt_service::{
|
||||
merge_system_prompt_with_memory_context, MemoryPromptContext,
|
||||
};
|
||||
use aster::conversation::message::Message;
|
||||
use futures::StreamExt;
|
||||
use lime_agent::{convert_agent_event, TauriAgentEvent, WriteArtifactEventEmitter};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use lime_agent::{
|
||||
convert_agent_event, get_persisted_session_metadata_sync,
|
||||
merge_system_prompt_with_runtime_agents, TauriAgentEvent, WriteArtifactEventEmitter,
|
||||
};
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
pub use lime_agent::{
|
||||
PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage,
|
||||
@@ -47,9 +55,39 @@ impl AsterAgentWrapper {
|
||||
let cancel_token = state.create_cancel_token(&session_id).await;
|
||||
|
||||
let user_message = Message::user().with_text(&message);
|
||||
let session_config = SessionConfigBuilder::new(&session_id)
|
||||
.include_context_trace(true)
|
||||
.build();
|
||||
let mut session_config_builder =
|
||||
SessionConfigBuilder::new(&session_id).include_context_trace(true);
|
||||
let persisted_session_metadata = get_persisted_session_metadata_sync(db, &session_id)
|
||||
.ok()
|
||||
.flatten();
|
||||
let persisted_prompt = persisted_session_metadata
|
||||
.as_ref()
|
||||
.and_then(|session| session.system_prompt.clone());
|
||||
let working_dir = persisted_session_metadata
|
||||
.as_ref()
|
||||
.and_then(|session| session.working_dir.as_deref())
|
||||
.filter(|path| !path.trim().is_empty())
|
||||
.map(Path::new);
|
||||
|
||||
let merged_prompt =
|
||||
if let Some(config_manager) = app.try_state::<GlobalConfigManagerState>() {
|
||||
let runtime_config = config_manager.config();
|
||||
merge_system_prompt_with_memory_context(
|
||||
merge_system_prompt_with_runtime_agents(persisted_prompt, working_dir),
|
||||
&runtime_config,
|
||||
MemoryPromptContext {
|
||||
working_dir,
|
||||
active_relative_path: None,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
merge_system_prompt_with_runtime_agents(persisted_prompt, working_dir)
|
||||
};
|
||||
|
||||
if let Some(prompt) = merged_prompt {
|
||||
session_config_builder = session_config_builder.system_prompt(prompt);
|
||||
}
|
||||
let session_config = session_config_builder.build();
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
|
||||
+48
-25
@@ -17,6 +17,12 @@ use super::types::{AppState, TrayManagerState};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
fn compiled_updater_public_key() -> Option<&'static str> {
|
||||
option_env!("LIME_UPDATER_PUBLIC_KEY")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool {
|
||||
minimize_to_tray && window_label == MAIN_WINDOW_LABEL
|
||||
}
|
||||
@@ -127,7 +133,7 @@ pub fn run() {
|
||||
let shared_stats_clone = shared_stats.clone();
|
||||
let shared_tokens_clone = shared_tokens.clone();
|
||||
let shared_logger_clone = shared_logger.clone();
|
||||
let update_check_service_clone = update_check_service_state.0.clone();
|
||||
let update_check_service_clone = update_check_service_state.clone();
|
||||
let gateway_tunnel_state = lime_gateway::tunnel::GatewayTunnelState::default();
|
||||
let gateway_tunnel_state_for_setup = gateway_tunnel_state.clone();
|
||||
let global_config_manager_for_setup = global_config_manager_state.clone();
|
||||
@@ -226,6 +232,17 @@ pub fn run() {
|
||||
}
|
||||
})
|
||||
.setup(move |app| {
|
||||
#[cfg(desktop)]
|
||||
if let Some(public_key) = compiled_updater_public_key() {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_updater::Builder::new()
|
||||
.pubkey(public_key)
|
||||
.build(),
|
||||
)?;
|
||||
} else {
|
||||
tracing::info!("[启动] 未注入 updater 公钥,跳过注册 updater 插件");
|
||||
}
|
||||
|
||||
// 启动时先最大化再显示,避免用户看到“先小窗后展开”的过程。
|
||||
if let Some(main_window) = app.get_webview_window("main") {
|
||||
reveal_main_window(&main_window);
|
||||
@@ -1085,14 +1102,16 @@ pub fn run() {
|
||||
// Path utility commands
|
||||
commands::config_cmd::expand_path,
|
||||
commands::config_cmd::open_auth_dir,
|
||||
commands::config_cmd::check_for_updates,
|
||||
commands::config_cmd::download_update,
|
||||
// OpenClaw commands
|
||||
commands::openclaw_cmd::openclaw_check_installed,
|
||||
commands::openclaw_cmd::openclaw_get_environment_status,
|
||||
commands::openclaw_cmd::openclaw_check_node_version,
|
||||
commands::openclaw_cmd::openclaw_check_git_available,
|
||||
commands::openclaw_cmd::openclaw_get_node_download_url,
|
||||
commands::claw_solution_cmd::claw_solution_list,
|
||||
commands::claw_solution_cmd::claw_solution_detail,
|
||||
commands::claw_solution_cmd::claw_solution_check_readiness,
|
||||
commands::claw_solution_cmd::claw_solution_prepare,
|
||||
commands::openclaw_cmd::openclaw_get_git_download_url,
|
||||
commands::openclaw_cmd::openclaw_get_command_preview,
|
||||
commands::openclaw_cmd::openclaw_get_progress_logs,
|
||||
@@ -1384,28 +1403,28 @@ pub fn run() {
|
||||
commands::agent_cmd::agent_get_process_status,
|
||||
commands::agent_cmd::agent_generate_title,
|
||||
// Aster Agent commands
|
||||
commands::aster_agent_cmd::aster_agent_init,
|
||||
commands::aster_agent_cmd::aster_agent_status,
|
||||
commands::aster_agent_cmd::aster_agent_reset,
|
||||
commands::aster_agent_cmd::aster_agent_configure_provider,
|
||||
commands::aster_agent_cmd::aster_agent_configure_from_pool,
|
||||
commands::aster_agent_cmd::agent_runtime_submit_turn,
|
||||
commands::aster_agent_cmd::agent_runtime_interrupt_turn,
|
||||
commands::aster_agent_cmd::agent_runtime_promote_queued_turn,
|
||||
commands::aster_agent_cmd::agent_runtime_remove_queued_turn,
|
||||
commands::aster_agent_cmd::agent_runtime_create_session,
|
||||
commands::aster_agent_cmd::agent_runtime_list_sessions,
|
||||
commands::aster_agent_cmd::agent_runtime_get_session,
|
||||
commands::aster_agent_cmd::agent_runtime_get_tool_inventory,
|
||||
commands::aster_agent_cmd::agent_runtime_spawn_subagent,
|
||||
commands::aster_agent_cmd::agent_runtime_send_subagent_input,
|
||||
commands::aster_agent_cmd::agent_runtime_wait_subagents,
|
||||
commands::aster_agent_cmd::agent_runtime_resume_subagent,
|
||||
commands::aster_agent_cmd::agent_runtime_close_subagent,
|
||||
commands::aster_agent_cmd::agent_runtime_update_session,
|
||||
commands::aster_agent_cmd::agent_runtime_delete_session,
|
||||
commands::aster_agent_cmd::agent_runtime_respond_action,
|
||||
commands::aster_agent_cmd::social_generate_cover_image_cmd,
|
||||
commands::aster_agent_cmd::command_api::provider_api::aster_agent_init,
|
||||
commands::aster_agent_cmd::command_api::provider_api::aster_agent_status,
|
||||
commands::aster_agent_cmd::command_api::provider_api::aster_agent_reset,
|
||||
commands::aster_agent_cmd::command_api::provider_api::aster_agent_configure_provider,
|
||||
commands::aster_agent_cmd::command_api::provider_api::aster_agent_configure_from_pool,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_submit_turn,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_interrupt_turn,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_promote_queued_turn,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_remove_queued_turn,
|
||||
commands::aster_agent_cmd::command_api::session_api::agent_runtime_create_session,
|
||||
commands::aster_agent_cmd::command_api::session_api::agent_runtime_list_sessions,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_session,
|
||||
commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_tool_inventory,
|
||||
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_spawn_subagent,
|
||||
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_send_subagent_input,
|
||||
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_wait_subagents,
|
||||
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_resume_subagent,
|
||||
commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_close_subagent,
|
||||
commands::aster_agent_cmd::command_api::session_api::agent_runtime_update_session,
|
||||
commands::aster_agent_cmd::action_runtime::agent_runtime_delete_session,
|
||||
commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action,
|
||||
commands::aster_agent_cmd::tool_runtime::social_tools::social_generate_cover_image_cmd,
|
||||
commands::theme_context_cmd::aster_agent_theme_context_search,
|
||||
// Models config commands
|
||||
commands::models_cmd::get_models_config,
|
||||
@@ -1564,6 +1583,8 @@ pub fn run() {
|
||||
commands::screenshot_cmd::send_screenshot_chat,
|
||||
// Update Check commands
|
||||
commands::update_cmd::check_update,
|
||||
commands::update_cmd::check_for_updates,
|
||||
commands::update_cmd::download_update,
|
||||
commands::update_cmd::get_update_check_settings,
|
||||
commands::update_cmd::set_update_check_settings,
|
||||
commands::update_cmd::get_update_notification_metrics,
|
||||
@@ -1736,6 +1757,8 @@ pub fn run() {
|
||||
commands::memory_management_cmd::memory_get_auto_index,
|
||||
commands::memory_management_cmd::memory_toggle_auto,
|
||||
commands::memory_management_cmd::memory_update_auto_note,
|
||||
commands::memory_management_cmd::memory_scaffold_runtime_agents_template,
|
||||
commands::memory_management_cmd::memory_ensure_workspace_local_agents_gitignore,
|
||||
// Unified Memory commands
|
||||
commands::unified_memory_cmd::unified_memory_list,
|
||||
commands::unified_memory_cmd::unified_memory_get,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
use super::*;
|
||||
|
||||
/// 统一运行时:删除会话。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_delete_session(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
let trimmed_session_id = session_id.trim().to_string();
|
||||
let _ = state.cancel_session(&trimmed_session_id).await;
|
||||
let _ = clear_runtime_queue_service(&app, &trimmed_session_id).await;
|
||||
delete_runtime_session_internal(db.inner(), &trimmed_session_id).await
|
||||
}
|
||||
|
||||
/// 确认权限请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfirmRequest {
|
||||
request_id: String,
|
||||
confirmed: bool,
|
||||
#[allow(dead_code)]
|
||||
response: Option<String>,
|
||||
}
|
||||
|
||||
async fn confirm_runtime_action_internal(
|
||||
state: &AsterAgentState,
|
||||
request: ConfirmRequest,
|
||||
) -> Result<(), String> {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 确认请求: id={}, confirmed={}",
|
||||
request.request_id,
|
||||
request.confirmed
|
||||
);
|
||||
|
||||
let permission = if request.confirmed {
|
||||
Permission::AllowOnce
|
||||
} else {
|
||||
Permission::DenyOnce
|
||||
};
|
||||
|
||||
let confirmation = PermissionConfirmation {
|
||||
principal_type: PrincipalType::Tool,
|
||||
permission,
|
||||
};
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
agent
|
||||
.handle_confirmation(request.request_id.clone(), confirmation)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Elicitation 回填请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SubmitElicitationResponseRequest {
|
||||
request_id: String,
|
||||
user_data: serde_json::Value,
|
||||
#[serde(default)]
|
||||
metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_elicitation_submission(
|
||||
session_id: &str,
|
||||
request_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let trimmed_session_id = session_id.trim().to_string();
|
||||
if trimmed_session_id.is_empty() {
|
||||
return Err("session_id 不能为空".to_string());
|
||||
}
|
||||
if request_id.trim().is_empty() {
|
||||
return Err("request_id 不能为空".to_string());
|
||||
}
|
||||
Ok(trimmed_session_id)
|
||||
}
|
||||
|
||||
pub(crate) fn build_action_resume_runtime_status() -> TauriRuntimeStatus {
|
||||
TauriRuntimeStatus {
|
||||
phase: "routing".to_string(),
|
||||
title: "已提交补充信息,继续执行中".to_string(),
|
||||
detail: "补充信息已回填到当前执行链路,正在恢复后续步骤。".to_string(),
|
||||
checkpoints: vec![
|
||||
"补充信息已确认".to_string(),
|
||||
"已唤醒当前执行链路".to_string(),
|
||||
"等待下一条执行事件".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_action_resume_runtime_status(app: &AppHandle, event_name: &str) {
|
||||
if event_name.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let event = TauriAgentEvent::RuntimeStatus {
|
||||
status: build_action_resume_runtime_status(),
|
||||
};
|
||||
if let Err(error) = app.emit(event_name, &event) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 发送 action resume runtime_status 失败: event_name={}, error={}",
|
||||
event_name,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_action_user_data(
|
||||
request: &AgentRuntimeRespondActionRequest,
|
||||
) -> serde_json::Value {
|
||||
if let Some(user_data) = request.user_data.clone() {
|
||||
return user_data;
|
||||
}
|
||||
|
||||
if !request.confirmed {
|
||||
return serde_json::Value::String(String::new());
|
||||
}
|
||||
|
||||
let Some(response) = request.response.as_ref() else {
|
||||
return serde_json::Value::String(String::new());
|
||||
};
|
||||
let trimmed = response.trim();
|
||||
if trimmed.is_empty() {
|
||||
return serde_json::Value::String(String::new());
|
||||
}
|
||||
|
||||
serde_json::from_str(trimmed).unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string()))
|
||||
}
|
||||
|
||||
/// 统一运行时:响应工具确认 / ask / elicitation。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_respond_action(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
request: AgentRuntimeRespondActionRequest,
|
||||
) -> Result<(), String> {
|
||||
match request.action_type {
|
||||
AgentRuntimeActionType::ToolConfirmation => {
|
||||
confirm_runtime_action_internal(
|
||||
state.inner(),
|
||||
ConfirmRequest {
|
||||
request_id: request.request_id.clone(),
|
||||
confirmed: request.confirmed,
|
||||
response: request.response.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
AgentRuntimeActionType::AskUser | AgentRuntimeActionType::Elicitation => {
|
||||
let user_data = build_runtime_action_user_data(&request);
|
||||
let resume_event_name = normalize_optional_text(request.event_name.clone());
|
||||
submit_runtime_elicitation_response_internal(
|
||||
state.inner(),
|
||||
request.session_id.clone(),
|
||||
SubmitElicitationResponseRequest {
|
||||
request_id: request.request_id.clone(),
|
||||
user_data,
|
||||
metadata: request.metadata.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|_| {
|
||||
if let Some(event_name) = resume_event_name.as_deref() {
|
||||
emit_action_resume_runtime_status(&app, event_name);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit_runtime_elicitation_response_internal(
|
||||
state: &AsterAgentState,
|
||||
session_id: String,
|
||||
request: SubmitElicitationResponseRequest,
|
||||
) -> Result<(), String> {
|
||||
let session_id = validate_elicitation_submission(&session_id, &request.request_id)?;
|
||||
|
||||
tracing::info!(
|
||||
"[AsterAgent] 提交 elicitation 响应: session={}, request_id={}",
|
||||
session_id,
|
||||
request.request_id
|
||||
);
|
||||
|
||||
let message =
|
||||
Message::user().with_content(MessageContent::action_required_elicitation_response(
|
||||
request.request_id.clone(),
|
||||
request.user_data,
|
||||
));
|
||||
|
||||
let mut session_config_builder =
|
||||
SessionConfigBuilder::new(&session_id).include_context_trace(true);
|
||||
if let Some(prompt) =
|
||||
merge_system_prompt_with_elicitation_context(None, request.metadata.as_ref())
|
||||
{
|
||||
session_config_builder = session_config_builder.system_prompt(prompt);
|
||||
}
|
||||
let session_config = session_config_builder.build();
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
|
||||
let mut stream = agent
|
||||
.reply(message, session_config, None)
|
||||
.await
|
||||
.map_err(|e| format!("提交 elicitation 响应失败: {e}"))?;
|
||||
|
||||
while let Some(event_result) = stream.next().await {
|
||||
match event_result {
|
||||
Ok(AgentEvent::Message(message)) => {
|
||||
let text = message.as_concat_text();
|
||||
if text.contains("Failed to submit elicitation response")
|
||||
|| text.contains("Request not found")
|
||||
{
|
||||
return Err(format!("提交 elicitation 响应失败: {text}"));
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Err(format!("提交 elicitation 响应失败: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const BROWSER_PROFILE_KEY_ENV_KEYS: &[&str] =
|
||||
&["LIME_BROWSER_PROFILE_KEY", "PROXYCAST_BROWSER_PROFILE_KEY"];
|
||||
pub(crate) const BROWSER_ASSIST_ALLOW_PATTERN: &str = "mcp__lime-browser__*";
|
||||
const BROWSER_ASSIST_DENY_PATTERNS: &[&str] = &["mcp__playwright__*", "browser_*", "playwright*"];
|
||||
|
||||
static BROWSER_ASSIST_RUNTIME_HINTS: OnceLock<
|
||||
tokio::sync::RwLock<HashMap<String, BrowserAssistRuntimeHint>>,
|
||||
> = OnceLock::new();
|
||||
|
||||
fn shared_browser_assist_runtime_hints(
|
||||
) -> &'static tokio::sync::RwLock<HashMap<String, BrowserAssistRuntimeHint>> {
|
||||
BROWSER_ASSIST_RUNTIME_HINTS.get_or_init(|| tokio::sync::RwLock::new(HashMap::new()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct BrowserAssistRuntimeHint {
|
||||
pub(crate) profile_key: String,
|
||||
pub(crate) preferred_backend: Option<BrowserBackendType>,
|
||||
pub(crate) auto_launch: bool,
|
||||
pub(crate) launch_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum BrowserTaskRequirement {
|
||||
Optional,
|
||||
Required,
|
||||
RequiredWithUserStep,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RuntimeChatMode {
|
||||
Agent,
|
||||
Creator,
|
||||
General,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_browser_backend_hint(value: &str) -> Option<BrowserBackendType> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"aster_compat" => Some(BrowserBackendType::AsterCompat),
|
||||
"lime_extension_bridge" => Some(BrowserBackendType::LimeExtensionBridge),
|
||||
"cdp_direct" => Some(BrowserBackendType::CdpDirect),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_browser_assist_runtime_hint(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<BrowserAssistRuntimeHint> {
|
||||
let browser_assist =
|
||||
extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"])?;
|
||||
let profile_key = ["profile_key", "profileKey"]
|
||||
.iter()
|
||||
.filter_map(|key| browser_assist.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?
|
||||
.to_string();
|
||||
let preferred_backend = ["preferred_backend", "preferredBackend"]
|
||||
.iter()
|
||||
.filter_map(|key| browser_assist.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.and_then(parse_browser_backend_hint);
|
||||
let auto_launch = ["auto_launch", "autoLaunch"]
|
||||
.iter()
|
||||
.filter_map(|key| browser_assist.get(*key))
|
||||
.find_map(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let launch_url = ["launch_url", "launchUrl", "url"]
|
||||
.iter()
|
||||
.filter_map(|key| browser_assist.get(*key))
|
||||
.find_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
Some(BrowserAssistRuntimeHint {
|
||||
profile_key,
|
||||
preferred_backend,
|
||||
auto_launch,
|
||||
launch_url,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn is_browser_assist_enabled(request_metadata: Option<&serde_json::Value>) -> bool {
|
||||
let Some(browser_assist) =
|
||||
extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"])
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Some(enabled) = ["enabled", "is_enabled", "isEnabled"]
|
||||
.iter()
|
||||
.filter_map(|key| browser_assist.get(*key))
|
||||
.find_map(serde_json::Value::as_bool)
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
|
||||
extract_browser_assist_runtime_hint(request_metadata).is_some() || !browser_assist.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn extract_browser_task_requirement(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<BrowserTaskRequirement> {
|
||||
match extract_harness_string(
|
||||
request_metadata,
|
||||
&["browser_requirement", "browserRequirement"],
|
||||
)
|
||||
.as_deref()
|
||||
{
|
||||
Some("optional") => Some(BrowserTaskRequirement::Optional),
|
||||
Some("required") => Some(BrowserTaskRequirement::Required),
|
||||
Some("required_with_user_step") => Some(BrowserTaskRequirement::RequiredWithUserStep),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_browser_requirement_to_request_tool_policy(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
request_web_search: Option<bool>,
|
||||
request_search_mode: Option<RequestToolPolicyMode>,
|
||||
) -> (Option<bool>, Option<RequestToolPolicyMode>) {
|
||||
match extract_browser_task_requirement(request_metadata) {
|
||||
Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep) => {
|
||||
(Some(false), Some(RequestToolPolicyMode::Disabled))
|
||||
}
|
||||
_ => (request_web_search, request_search_mode),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_session_scoped_permission_conditions(session_id: &str) -> Vec<PermissionCondition> {
|
||||
let session_id = session_id.trim();
|
||||
if session_id.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![PermissionCondition {
|
||||
condition_type: ConditionType::Session,
|
||||
field: Some("session_id".to_string()),
|
||||
operator: ConditionOperator::Equals,
|
||||
value: serde_json::json!(session_id),
|
||||
validator: None,
|
||||
description: Some("仅对当前聊天会话生效".to_string()),
|
||||
}]
|
||||
}
|
||||
|
||||
pub(crate) fn append_browser_assist_session_permissions(
|
||||
permissions: &mut Vec<ToolPermission>,
|
||||
session_id: &str,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) {
|
||||
if !is_browser_assist_enabled(request_metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
let conditions = build_session_scoped_permission_conditions(session_id);
|
||||
permissions.push(ToolPermission {
|
||||
tool: BROWSER_ASSIST_ALLOW_PATTERN.to_string(),
|
||||
allowed: true,
|
||||
priority: 1100,
|
||||
conditions: conditions.clone(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some(
|
||||
"Browser Assist 会话已启用:网页任务应统一走 Lime 浏览器运行时工具".to_string(),
|
||||
),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
|
||||
for pattern in BROWSER_ASSIST_DENY_PATTERNS {
|
||||
permissions.push(ToolPermission {
|
||||
tool: (*pattern).to_string(),
|
||||
allowed: false,
|
||||
priority: 1200,
|
||||
conditions: conditions.clone(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some(
|
||||
"Browser Assist 会话禁止回退到 Playwright 浏览器工具;请改用 mcp__lime-browser__*,以便右侧画布附着实时浏览器会话"
|
||||
.to_string(),
|
||||
),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_browser_assist_runtime_hint(
|
||||
session_id: &str,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) {
|
||||
let mut hints = shared_browser_assist_runtime_hints().write().await;
|
||||
if let Some(hint) = extract_browser_assist_runtime_hint(request_metadata) {
|
||||
hints.insert(session_id.to_string(), hint);
|
||||
} else {
|
||||
hints.remove(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_browser_assist_runtime_hint(
|
||||
session_id: &str,
|
||||
) -> Option<BrowserAssistRuntimeHint> {
|
||||
shared_browser_assist_runtime_hints()
|
||||
.read()
|
||||
.await
|
||||
.get(session_id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_runtime_chat_mode(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> RuntimeChatMode {
|
||||
if let Some(chat_mode) = extract_harness_string(request_metadata, &["chat_mode", "chatMode"]) {
|
||||
match chat_mode.as_str() {
|
||||
"general" => return RuntimeChatMode::General,
|
||||
"creator" => return RuntimeChatMode::Creator,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match extract_harness_string(request_metadata, &["theme", "harness_theme"]).as_deref() {
|
||||
Some("general" | "knowledge" | "planning") => RuntimeChatMode::General,
|
||||
_ => RuntimeChatMode::Agent,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn runtime_chat_mode_label(mode: RuntimeChatMode) -> &'static str {
|
||||
match mode {
|
||||
RuntimeChatMode::Agent => "agent",
|
||||
RuntimeChatMode::Creator => "creator",
|
||||
RuntimeChatMode::General => "general",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_web_search_enabled_for_chat_mode(_chat_mode: RuntimeChatMode) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn should_enable_model_skill_tool(request_metadata: Option<&serde_json::Value>) -> bool {
|
||||
if let Some(explicit) = extract_harness_bool(
|
||||
request_metadata,
|
||||
&["allow_model_skills", "allowModelSkills"],
|
||||
) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
matches!(
|
||||
extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(),
|
||||
Some("theme_workbench")
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use super::*;
|
||||
use crate::commands::aster_agent_cmd::runtime_turn::build_runtime_queue_executor;
|
||||
use crate::commands::aster_agent_cmd::session_runtime::{
|
||||
create_runtime_session_internal, list_runtime_sessions_internal,
|
||||
rename_runtime_session_internal, update_runtime_session_execution_strategy_internal,
|
||||
};
|
||||
use crate::commands::aster_agent_cmd::subagent_runtime::{
|
||||
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
|
||||
agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal,
|
||||
agent_runtime_wait_subagents_internal, SubagentControlRuntime,
|
||||
};
|
||||
use crate::commands::aster_agent_cmd::tool_runtime::ensure_tool_search_tool_registered;
|
||||
|
||||
#[path = "command_api/provider_api.rs"]
|
||||
pub(crate) mod provider_api;
|
||||
#[path = "command_api/runtime_api.rs"]
|
||||
pub(crate) mod runtime_api;
|
||||
#[path = "command_api/session_api.rs"]
|
||||
pub(crate) mod session_api;
|
||||
#[path = "command_api/subagent_api.rs"]
|
||||
pub(crate) mod subagent_api;
|
||||
|
||||
fn build_subagent_control_runtime(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
) -> SubagentControlRuntime {
|
||||
SubagentControlRuntime::new(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) use provider_api::{
|
||||
aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init,
|
||||
aster_agent_reset, aster_agent_status,
|
||||
};
|
||||
pub(crate) use runtime_api::{
|
||||
agent_runtime_get_session, agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn,
|
||||
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn, agent_runtime_submit_turn,
|
||||
};
|
||||
pub(crate) use session_api::{
|
||||
agent_runtime_create_session, agent_runtime_list_sessions, agent_runtime_update_session,
|
||||
};
|
||||
pub(crate) use subagent_api::{
|
||||
agent_runtime_close_subagent, agent_runtime_resume_subagent, agent_runtime_send_subagent_input,
|
||||
agent_runtime_spawn_subagent, agent_runtime_wait_subagents,
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
use super::*;
|
||||
|
||||
fn build_agent_status(
|
||||
initialized: bool,
|
||||
provider_config: Option<ProviderConfig>,
|
||||
) -> AsterAgentStatus {
|
||||
AsterAgentStatus {
|
||||
initialized,
|
||||
provider_configured: provider_config.is_some(),
|
||||
provider_name: provider_config.as_ref().map(|c| c.provider_name.clone()),
|
||||
model_name: provider_config.as_ref().map(|c| c.model_name.clone()),
|
||||
credential_uuid: provider_config.and_then(|c| c.credential_uuid),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_init(
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
) -> Result<AsterAgentStatus, String> {
|
||||
tracing::info!("[AsterAgent] 初始化 Agent");
|
||||
|
||||
state.init_agent_with_db(&db).await?;
|
||||
ensure_tool_search_tool_registered(state.inner()).await?;
|
||||
|
||||
let provider_config = state.get_provider_config().await;
|
||||
|
||||
tracing::info!("[AsterAgent] Agent 初始化成功");
|
||||
|
||||
Ok(build_agent_status(true, provider_config))
|
||||
}
|
||||
|
||||
/// 配置 Aster Agent 的 Provider
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_configure_provider(
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
request: ConfigureProviderRequest,
|
||||
session_id: String,
|
||||
) -> Result<AsterAgentStatus, String> {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 配置 Provider: {} / {}",
|
||||
request.provider_name,
|
||||
request.model_name
|
||||
);
|
||||
|
||||
let provider_selector = request
|
||||
.provider_id
|
||||
.clone()
|
||||
.or_else(|| Some(request.provider_name.clone()));
|
||||
let config = ProviderConfig {
|
||||
provider_name: request.provider_name,
|
||||
provider_selector,
|
||||
model_name: request.model_name,
|
||||
api_key: request.api_key,
|
||||
base_url: request.base_url,
|
||||
credential_uuid: None,
|
||||
force_responses_api: false,
|
||||
credential_path: None,
|
||||
};
|
||||
|
||||
state
|
||||
.configure_provider(config.clone(), &session_id, &db)
|
||||
.await?;
|
||||
persist_session_provider_routing(
|
||||
&session_id,
|
||||
config
|
||||
.provider_selector
|
||||
.as_deref()
|
||||
.unwrap_or(&config.provider_name),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(AsterAgentStatus {
|
||||
initialized: true,
|
||||
provider_configured: true,
|
||||
provider_name: Some(config.provider_name),
|
||||
model_name: Some(config.model_name),
|
||||
credential_uuid: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从凭证池配置 Aster Agent 的 Provider
|
||||
///
|
||||
/// 自动从 Lime 凭证池选择可用凭证并配置 Aster Provider
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_configure_from_pool(
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
request: ConfigureFromPoolRequest,
|
||||
session_id: String,
|
||||
) -> Result<AsterAgentStatus, String> {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 从凭证池配置 Provider: {} / {}",
|
||||
request.provider_type,
|
||||
request.model_name
|
||||
);
|
||||
|
||||
let aster_config = state
|
||||
.configure_provider_from_pool(
|
||||
&db,
|
||||
&request.provider_type,
|
||||
&request.model_name,
|
||||
&session_id,
|
||||
)
|
||||
.await?;
|
||||
persist_session_provider_routing(&session_id, &request.provider_type).await?;
|
||||
|
||||
Ok(AsterAgentStatus {
|
||||
initialized: true,
|
||||
provider_configured: true,
|
||||
provider_name: Some(aster_config.provider_name),
|
||||
model_name: Some(aster_config.model_name),
|
||||
credential_uuid: Some(aster_config.credential_uuid),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 Aster Agent 状态
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_status(
|
||||
state: State<'_, AsterAgentState>,
|
||||
) -> Result<AsterAgentStatus, String> {
|
||||
let provider_config = state.get_provider_config().await;
|
||||
Ok(build_agent_status(
|
||||
state.is_initialized().await,
|
||||
provider_config,
|
||||
))
|
||||
}
|
||||
|
||||
/// 重置 Aster Agent
|
||||
///
|
||||
/// 清除当前 Provider 配置,下次对话时会重新从凭证池选择凭证。
|
||||
/// 用于切换凭证后无需重启应用即可生效。
|
||||
#[tauri::command]
|
||||
pub async fn aster_agent_reset(
|
||||
state: State<'_, AsterAgentState>,
|
||||
) -> Result<AsterAgentStatus, String> {
|
||||
tracing::info!("[AsterAgent] 重置 Agent Provider 配置");
|
||||
|
||||
state.clear_provider_config().await;
|
||||
|
||||
Ok(AsterAgentStatus {
|
||||
initialized: state.is_initialized().await,
|
||||
provider_configured: false,
|
||||
provider_name: None,
|
||||
model_name: None,
|
||||
credential_uuid: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
use super::*;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_submit_turn(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeSubmitTurnRequest,
|
||||
) -> Result<(), String> {
|
||||
let runtime_request: AsterChatRequest = request.into();
|
||||
let queue_if_busy = runtime_request.queue_if_busy.unwrap_or(false);
|
||||
let queued_task = build_queued_turn_task(runtime_request)?;
|
||||
submit_runtime_turn_service(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
queued_task,
|
||||
queue_if_busy,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 统一运行时:中断当前 turn。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_interrupt_turn(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
request: AgentRuntimeInterruptTurnRequest,
|
||||
) -> Result<bool, String> {
|
||||
let session_id = request.session_id;
|
||||
let cancelled = state.cancel_session(&session_id).await;
|
||||
let cleared = clear_runtime_queue_service(&app, &session_id).await?;
|
||||
Ok(cancelled || !cleared.is_empty())
|
||||
}
|
||||
|
||||
/// 统一运行时:获取会话详情。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_get_session(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
session_id: String,
|
||||
) -> Result<AgentRuntimeSessionDetail, String> {
|
||||
tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id);
|
||||
let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?;
|
||||
|
||||
if let Err(error) = resume_runtime_queue_if_needed_service(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
session_id.clone(),
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Queue] 获取会话后恢复排队执行失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?;
|
||||
Ok(AgentRuntimeSessionDetail::from_session_detail(
|
||||
detail,
|
||||
queued_turns,
|
||||
))
|
||||
}
|
||||
|
||||
/// 统一运行时:获取工具库存快照。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_get_tool_inventory(
|
||||
state: State<'_, AsterAgentState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
request: Option<AgentRuntimeToolInventoryRequest>,
|
||||
) -> Result<crate::agent_tools::inventory::AgentToolInventorySnapshot, String> {
|
||||
let request = request.unwrap_or_default();
|
||||
let caller = lime_core::tool_calling::normalize_tool_caller(request.caller.as_deref())
|
||||
.unwrap_or_else(|| "assistant".to_string());
|
||||
let surface = match (request.creator, request.browser_assist) {
|
||||
(true, true) => WorkspaceToolSurface::creator_with_browser_assist(),
|
||||
(true, false) => WorkspaceToolSurface::creator(),
|
||||
(false, true) => WorkspaceToolSurface::browser_assist(),
|
||||
(false, false) => WorkspaceToolSurface::core(),
|
||||
};
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let (mcp_server_names, mcp_tools) = {
|
||||
let manager = mcp_manager.lock().await;
|
||||
let server_names = manager.get_running_servers().await;
|
||||
let tools = match manager.list_tools().await {
|
||||
Ok(tools) => tools,
|
||||
Err(error) => {
|
||||
warnings.push(format!("读取 MCP 工具列表失败: {error}"));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
(server_names, tools)
|
||||
};
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let Some(agent) = guard.as_ref() else {
|
||||
return Ok(build_tool_inventory(AgentToolInventoryBuildInput {
|
||||
surface,
|
||||
caller,
|
||||
agent_initialized: false,
|
||||
warnings: {
|
||||
warnings.push(
|
||||
"Aster Agent 尚未初始化,runtime registry / extension 快照为空".to_string(),
|
||||
);
|
||||
warnings
|
||||
},
|
||||
persisted_execution_policy: Some(config_manager.config().agent.tool_execution),
|
||||
request_metadata: request.metadata.clone(),
|
||||
mcp_server_names,
|
||||
mcp_tools,
|
||||
registry_definitions: Vec::new(),
|
||||
extension_configs: Vec::new(),
|
||||
visible_extension_tools: Vec::new(),
|
||||
searchable_extension_tools: Vec::new(),
|
||||
}));
|
||||
};
|
||||
|
||||
let registry_arc = agent.tool_registry().clone();
|
||||
let registry = registry_arc.read().await;
|
||||
let registry_definitions = registry.get_definitions();
|
||||
drop(registry);
|
||||
|
||||
let extension_configs = agent.get_extension_configs().await;
|
||||
let extension_manager = agent.extension_manager.clone();
|
||||
let visible_extension_tools = match extension_manager.get_prefixed_tools(None).await {
|
||||
Ok(tools) => tools
|
||||
.into_iter()
|
||||
.map(|tool| ExtensionToolInventorySeed {
|
||||
name: tool.name.to_string(),
|
||||
description: tool.description.clone().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
warnings.push(format!("读取已加载 extension tools 失败: {error}"));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let searchable_extension_tools =
|
||||
match extension_manager.get_prefixed_tools_for_search(None).await {
|
||||
Ok(tools) => tools
|
||||
.into_iter()
|
||||
.map(|tool| ExtensionToolInventorySeed {
|
||||
name: tool.name.to_string(),
|
||||
description: tool.description.clone().unwrap_or_default().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
warnings.push(format!("读取 extension 搜索工具面失败: {error}"));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(build_tool_inventory(AgentToolInventoryBuildInput {
|
||||
surface,
|
||||
caller,
|
||||
agent_initialized: true,
|
||||
warnings,
|
||||
persisted_execution_policy: Some(config_manager.config().agent.tool_execution),
|
||||
request_metadata: request.metadata.clone(),
|
||||
mcp_server_names,
|
||||
mcp_tools,
|
||||
registry_definitions,
|
||||
extension_configs,
|
||||
visible_extension_tools,
|
||||
searchable_extension_tools,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 统一运行时:移除单个排队 turn。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_remove_queued_turn(
|
||||
app: AppHandle,
|
||||
request: AgentRuntimeRemoveQueuedTurnRequest,
|
||||
) -> Result<bool, String> {
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
let queued_turn_id = request.queued_turn_id.trim().to_string();
|
||||
if session_id.is_empty() || queued_turn_id.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
remove_runtime_queued_turn_service(&app, &session_id, &queued_turn_id).await
|
||||
}
|
||||
|
||||
/// 统一运行时:将指定排队 turn 提前到下一条执行。
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_promote_queued_turn(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimePromoteQueuedTurnRequest,
|
||||
) -> Result<bool, String> {
|
||||
let session_id = request.session_id.trim().to_string();
|
||||
let queued_turn_id = request.queued_turn_id.trim().to_string();
|
||||
if session_id.is_empty() || queued_turn_id.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let promoted = promote_runtime_queued_turn_service(&session_id, &queued_turn_id).await?;
|
||||
if !promoted {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let _ = state.cancel_session(&session_id).await;
|
||||
let _ = resume_runtime_queue_if_needed_service(
|
||||
app,
|
||||
state.inner(),
|
||||
db.inner(),
|
||||
api_key_provider_service.inner(),
|
||||
logs.inner(),
|
||||
config_manager.inner(),
|
||||
mcp_manager.inner(),
|
||||
automation_state.inner(),
|
||||
session_id,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use super::*;
|
||||
|
||||
/// 创建新会话
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_create_session(
|
||||
db: State<'_, DbConnection>,
|
||||
workspace_id: String,
|
||||
name: Option<String>,
|
||||
execution_strategy: Option<AsterExecutionStrategy>,
|
||||
) -> Result<String, String> {
|
||||
create_runtime_session_internal(db.inner(), None, workspace_id, name, execution_strategy).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_list_sessions(
|
||||
db: State<'_, DbConnection>,
|
||||
logs: State<'_, LogState>,
|
||||
) -> Result<Vec<SessionInfo>, String> {
|
||||
let started_at = Instant::now();
|
||||
logs.write()
|
||||
.await
|
||||
.add("info", "[AgentDiag] agent_runtime_list_sessions.start");
|
||||
|
||||
match list_runtime_sessions_internal(db.inner()) {
|
||||
Ok(sessions) => {
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[AgentDiag] agent_runtime_list_sessions.success duration_ms={} sessions={}",
|
||||
started_at.elapsed().as_millis(),
|
||||
sessions.len()
|
||||
),
|
||||
);
|
||||
Ok(sessions)
|
||||
}
|
||||
Err(error) => {
|
||||
logs.write().await.add(
|
||||
"error",
|
||||
&format!(
|
||||
"[AgentDiag] agent_runtime_list_sessions.error duration_ms={} error={}",
|
||||
started_at.elapsed().as_millis(),
|
||||
crate::logger::sanitize_log_message(&error)
|
||||
),
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_update_session(
|
||||
db: State<'_, DbConnection>,
|
||||
request: AgentRuntimeUpdateSessionRequest,
|
||||
) -> Result<(), String> {
|
||||
let trimmed_session_id = request.session_id.trim().to_string();
|
||||
if trimmed_session_id.is_empty() {
|
||||
return Err("session_id 不能为空".to_string());
|
||||
}
|
||||
|
||||
if let Some(name) = request.name.as_ref() {
|
||||
let normalized_name = name.trim();
|
||||
if !normalized_name.is_empty() {
|
||||
rename_runtime_session_internal(db.inner(), &trimmed_session_id, normalized_name)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(execution_strategy) = request.execution_strategy {
|
||||
update_runtime_session_execution_strategy_internal(
|
||||
db.inner(),
|
||||
&trimmed_session_id,
|
||||
execution_strategy,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use super::*;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_spawn_subagent(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeSpawnSubagentRequest,
|
||||
) -> Result<AgentRuntimeSpawnSubagentResponse, String> {
|
||||
agent_runtime_spawn_subagent_internal(
|
||||
&build_subagent_control_runtime(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_send_subagent_input(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeSendSubagentInputRequest,
|
||||
) -> Result<AgentRuntimeSendSubagentInputResponse, String> {
|
||||
agent_runtime_send_subagent_input_internal(
|
||||
&build_subagent_control_runtime(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_wait_subagents(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeWaitSubagentsRequest,
|
||||
) -> Result<AgentRuntimeWaitSubagentsResponse, String> {
|
||||
agent_runtime_wait_subagents_internal(
|
||||
&build_subagent_control_runtime(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_resume_subagent(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeResumeSubagentRequest,
|
||||
) -> Result<AgentRuntimeResumeSubagentResponse, String> {
|
||||
agent_runtime_resume_subagent_internal(
|
||||
&build_subagent_control_runtime(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn agent_runtime_close_subagent(
|
||||
app: AppHandle,
|
||||
state: State<'_, AsterAgentState>,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
|
||||
logs: State<'_, LogState>,
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
mcp_manager: State<'_, McpManagerState>,
|
||||
automation_state: State<'_, AutomationServiceState>,
|
||||
request: AgentRuntimeCloseSubagentRequest,
|
||||
) -> Result<AgentRuntimeCloseSubagentResponse, String> {
|
||||
agent_runtime_close_subagent_internal(
|
||||
&build_subagent_control_runtime(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
use super::*;
|
||||
|
||||
/// Aster Agent 状态信息
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AsterAgentStatus {
|
||||
pub initialized: bool,
|
||||
pub provider_configured: bool,
|
||||
pub provider_name: Option<String>,
|
||||
pub model_name: Option<String>,
|
||||
/// 凭证 UUID(来自凭证池)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_uuid: Option<String>,
|
||||
}
|
||||
|
||||
/// Provider 配置请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigureProviderRequest {
|
||||
#[serde(default)]
|
||||
pub provider_id: Option<String>,
|
||||
pub provider_name: String,
|
||||
pub model_name: String,
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 从凭证池配置 Provider 的请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConfigureFromPoolRequest {
|
||||
/// Provider 类型 (openai, anthropic, kiro, gemini 等)
|
||||
pub provider_type: String,
|
||||
/// 模型名称
|
||||
pub model_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentRuntimeToolInventoryRequest {
|
||||
#[serde(default)]
|
||||
pub creator: bool,
|
||||
#[serde(default)]
|
||||
pub browser_assist: bool,
|
||||
#[serde(default)]
|
||||
pub caller: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 发送消息请求参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AsterChatRequest {
|
||||
pub message: String,
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(alias = "eventName")]
|
||||
pub event_name: String,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub images: Option<Vec<ImageInput>>,
|
||||
/// Provider 配置(可选,如果未配置则使用当前配置)
|
||||
#[serde(default, alias = "providerConfig")]
|
||||
pub provider_config: Option<ConfigureProviderRequest>,
|
||||
/// 项目 ID(可选,用于注入项目上下文到 System Prompt)
|
||||
#[serde(default, alias = "projectId")]
|
||||
pub project_id: Option<String>,
|
||||
/// Workspace ID(必填,用于校验会话与工作区一致性)
|
||||
#[serde(alias = "workspaceId")]
|
||||
pub workspace_id: String,
|
||||
/// 是否强制开启联网搜索工具策略
|
||||
#[serde(default, alias = "webSearch")]
|
||||
pub web_search: Option<bool>,
|
||||
/// 联网搜索模式(disabled / allowed / required)
|
||||
#[serde(default, alias = "searchMode")]
|
||||
pub search_mode: Option<RequestToolPolicyMode>,
|
||||
/// 执行策略(react / code_orchestrated / auto)
|
||||
#[serde(default, alias = "executionStrategy")]
|
||||
pub execution_strategy: Option<AsterExecutionStrategy>,
|
||||
/// 自动续写策略(用于文稿续写等场景)
|
||||
#[serde(default, alias = "autoContinue")]
|
||||
pub auto_continue: Option<AutoContinuePayload>,
|
||||
/// 前端传入的 System Prompt(可选,优先级低于项目上下文)
|
||||
#[serde(default, alias = "systemPrompt")]
|
||||
pub system_prompt: Option<String>,
|
||||
/// 请求级元数据(可选,用于 harness / 主题工作台状态对齐)
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// 回合 ID(可选,由前端提供时透传到 Aster runtime)
|
||||
#[serde(default, alias = "turnId")]
|
||||
pub turn_id: Option<String>,
|
||||
/// 会话忙时是否进入后端队列
|
||||
#[serde(default, alias = "queueIfBusy")]
|
||||
pub queue_if_busy: Option<bool>,
|
||||
/// 队列项 ID(由前端或后端生成)
|
||||
#[serde(default, alias = "queuedTurnId")]
|
||||
pub queued_turn_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentTurnConfigSnapshot {
|
||||
#[serde(default, alias = "providerConfig")]
|
||||
pub provider_config: Option<ConfigureProviderRequest>,
|
||||
#[serde(default, alias = "executionStrategy")]
|
||||
pub execution_strategy: Option<AsterExecutionStrategy>,
|
||||
#[serde(default, alias = "webSearch")]
|
||||
pub web_search: Option<bool>,
|
||||
#[serde(default, alias = "searchMode")]
|
||||
pub search_mode: Option<RequestToolPolicyMode>,
|
||||
#[serde(default, alias = "autoContinue")]
|
||||
pub auto_continue: Option<AutoContinuePayload>,
|
||||
#[serde(default, alias = "systemPrompt")]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeSubmitTurnRequest {
|
||||
pub message: String,
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(alias = "eventName")]
|
||||
pub event_name: String,
|
||||
#[serde(default)]
|
||||
pub images: Option<Vec<ImageInput>>,
|
||||
#[serde(alias = "workspaceId")]
|
||||
pub workspace_id: String,
|
||||
#[serde(default, alias = "turnConfig")]
|
||||
pub turn_config: Option<AgentTurnConfigSnapshot>,
|
||||
#[serde(default, alias = "turnId")]
|
||||
#[allow(dead_code)]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(default, alias = "queueIfBusy")]
|
||||
pub queue_if_busy: Option<bool>,
|
||||
#[serde(default, alias = "queuedTurnId")]
|
||||
pub queued_turn_id: Option<String>,
|
||||
}
|
||||
|
||||
impl From<AgentRuntimeSubmitTurnRequest> for AsterChatRequest {
|
||||
fn from(request: AgentRuntimeSubmitTurnRequest) -> Self {
|
||||
let turn_config = request.turn_config;
|
||||
Self {
|
||||
message: request.message,
|
||||
session_id: request.session_id,
|
||||
event_name: request.event_name,
|
||||
images: request.images,
|
||||
provider_config: turn_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.provider_config.clone()),
|
||||
project_id: None,
|
||||
workspace_id: request.workspace_id,
|
||||
web_search: turn_config.as_ref().and_then(|config| config.web_search),
|
||||
search_mode: turn_config.as_ref().and_then(|config| config.search_mode),
|
||||
execution_strategy: turn_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.execution_strategy),
|
||||
auto_continue: turn_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.auto_continue.clone()),
|
||||
system_prompt: turn_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.system_prompt.clone()),
|
||||
metadata: turn_config.and_then(|config| config.metadata),
|
||||
turn_id: request.turn_id,
|
||||
queue_if_busy: request.queue_if_busy,
|
||||
queued_turn_id: request.queued_turn_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeInterruptTurnRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(default, alias = "turnId")]
|
||||
#[allow(dead_code)]
|
||||
pub turn_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeRemoveQueuedTurnRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(alias = "queuedTurnId")]
|
||||
pub queued_turn_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimePromoteQueuedTurnRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(alias = "queuedTurnId")]
|
||||
pub queued_turn_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeSessionDetail {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub thread_id: String,
|
||||
pub messages: Vec<lime_agent::event_converter::TauriMessage>,
|
||||
pub execution_strategy: Option<String>,
|
||||
pub turns: Vec<lime_core::database::dao::agent_timeline::AgentThreadTurn>,
|
||||
pub items: Vec<lime_core::database::dao::agent_timeline::AgentThreadItem>,
|
||||
#[serde(default)]
|
||||
pub todo_items: Vec<lime_agent::SessionTodoItem>,
|
||||
#[serde(default)]
|
||||
pub queued_turns: Vec<QueuedTurnSnapshot>,
|
||||
#[serde(default)]
|
||||
pub child_subagent_sessions: Vec<lime_agent::ChildSubagentSession>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_parent_context: Option<lime_agent::SubagentParentContext>,
|
||||
}
|
||||
|
||||
impl AgentRuntimeSessionDetail {
|
||||
pub(crate) fn from_session_detail(
|
||||
detail: SessionDetail,
|
||||
queued_turns: Vec<QueuedTurnSnapshot>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: detail.id,
|
||||
name: detail.name,
|
||||
created_at: detail.created_at,
|
||||
updated_at: detail.updated_at,
|
||||
thread_id: detail.thread_id,
|
||||
messages: detail.messages,
|
||||
execution_strategy: detail.execution_strategy,
|
||||
turns: detail.turns,
|
||||
items: detail.items,
|
||||
todo_items: detail.todo_items,
|
||||
queued_turns,
|
||||
child_subagent_sessions: detail.child_subagent_sessions,
|
||||
subagent_parent_context: detail.subagent_parent_context,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentRuntimeSpawnSubagentRequest {
|
||||
#[serde(alias = "parentSessionId")]
|
||||
pub parent_session_id: String,
|
||||
pub message: String,
|
||||
#[serde(default, alias = "agentType")]
|
||||
pub agent_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default, alias = "reasoningEffort")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(default, alias = "forkContext")]
|
||||
pub fork_context: bool,
|
||||
#[serde(default, alias = "profileId")]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default, alias = "profileName")]
|
||||
pub profile_name: Option<String>,
|
||||
#[serde(default, alias = "roleKey")]
|
||||
pub role_key: Option<String>,
|
||||
#[serde(default, alias = "skillIds")]
|
||||
pub skill_ids: Vec<String>,
|
||||
#[serde(default, alias = "skillDirectories")]
|
||||
pub skill_directories: Vec<String>,
|
||||
#[serde(default, alias = "teamPresetId")]
|
||||
pub team_preset_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
#[serde(default, alias = "systemOverlay")]
|
||||
pub system_overlay: Option<String>,
|
||||
#[serde(default, alias = "outputContract")]
|
||||
pub output_contract: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeSpawnSubagentResponse {
|
||||
#[serde(alias = "agentId")]
|
||||
pub agent_id: String,
|
||||
#[serde(default)]
|
||||
pub nickname: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentRuntimeSendSubagentInputRequest {
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub interrupt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeSendSubagentInputResponse {
|
||||
#[serde(alias = "submissionId")]
|
||||
pub submission_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentRuntimeWaitSubagentsRequest {
|
||||
pub ids: Vec<String>,
|
||||
#[serde(default, alias = "timeoutMs")]
|
||||
pub timeout_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeWaitSubagentsResponse {
|
||||
pub status: HashMap<String, SubagentRuntimeStatus>,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentRuntimeResumeSubagentRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeResumeSubagentResponse {
|
||||
pub status: SubagentRuntimeStatus,
|
||||
pub cascade_session_ids: Vec<String>,
|
||||
pub changed_session_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentRuntimeCloseSubagentRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentRuntimeCloseSubagentResponse {
|
||||
pub previous_status: SubagentRuntimeStatus,
|
||||
pub cascade_session_ids: Vec<String>,
|
||||
pub changed_session_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentRuntimeActionType {
|
||||
ToolConfirmation,
|
||||
AskUser,
|
||||
Elicitation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeRespondActionRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(alias = "requestId")]
|
||||
pub request_id: String,
|
||||
#[serde(alias = "actionType")]
|
||||
pub action_type: AgentRuntimeActionType,
|
||||
pub confirmed: bool,
|
||||
#[serde(default)]
|
||||
pub response: Option<String>,
|
||||
#[serde(default, alias = "userData")]
|
||||
pub user_data: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(default, alias = "eventName")]
|
||||
pub event_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeUpdateSessionRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, alias = "executionStrategy")]
|
||||
pub execution_strategy: Option<AsterExecutionStrategy>,
|
||||
}
|
||||
|
||||
/// 自动续写参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AutoContinuePayload {
|
||||
/// 主开关
|
||||
pub enabled: bool,
|
||||
/// 快速模式
|
||||
#[serde(default, alias = "fastModeEnabled")]
|
||||
pub fast_mode_enabled: bool,
|
||||
/// 续写长度:0=短、1=中、2=长
|
||||
#[serde(default, alias = "continuationLength")]
|
||||
pub continuation_length: u8,
|
||||
/// 灵敏度:0-100
|
||||
#[serde(default)]
|
||||
pub sensitivity: u8,
|
||||
/// 来源标识
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
impl AutoContinuePayload {
|
||||
pub(crate) fn normalized(mut self) -> Self {
|
||||
self.continuation_length = self.continuation_length.min(2);
|
||||
self.sensitivity = self.sensitivity.min(100);
|
||||
self.source = self
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn length_instruction(&self) -> &'static str {
|
||||
match self.continuation_length.min(2) {
|
||||
0 => "短(补全 1-2 段,聚焦核心信息)",
|
||||
1 => "中(补全 3-5 段,兼顾结构与细节)",
|
||||
_ => "长(扩展为可发布草稿,结构完整)",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sensitivity_instruction(&self) -> &'static str {
|
||||
match self.sensitivity.min(100) {
|
||||
0..=33 => "低:优先稳健延续原文表达",
|
||||
34..=66 => "中:保持一致性并适度优化表达",
|
||||
_ => "高:在不偏题前提下积极补充观点亮点",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) async fn inject_mcp_extensions(
|
||||
state: &AsterAgentState,
|
||||
mcp_manager: &McpManagerState,
|
||||
) -> (usize, usize) {
|
||||
let manager = mcp_manager.lock().await;
|
||||
let running_servers = manager.get_running_servers().await;
|
||||
|
||||
if running_servers.is_empty() {
|
||||
tracing::debug!("[AsterAgent] 没有运行中的 MCP servers,跳过注入");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = match guard.as_ref() {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
tracing::warn!("[AsterAgent] Agent 未初始化,无法注入 MCP extensions");
|
||||
return (0, running_servers.len());
|
||||
}
|
||||
};
|
||||
|
||||
let all_tools = match manager.list_tools().await {
|
||||
Ok(tools) => tools,
|
||||
Err(error) => {
|
||||
tracing::warn!("[AsterAgent] 读取 MCP 工具列表失败,跳过注入: {}", error);
|
||||
return (0, running_servers.len());
|
||||
}
|
||||
};
|
||||
let mut tools_by_server: HashMap<String, Vec<crate::mcp::McpToolDefinition>> = HashMap::new();
|
||||
for tool in all_tools {
|
||||
tools_by_server
|
||||
.entry(tool.server_name.clone())
|
||||
.or_default()
|
||||
.push(tool);
|
||||
}
|
||||
|
||||
let clients_handle = manager.clients();
|
||||
let clients = clients_handle.read().await;
|
||||
let mut success_count = 0usize;
|
||||
let mut fail_count = 0usize;
|
||||
|
||||
for server_name in &running_servers {
|
||||
// 检查是否已注册(避免重复注册)
|
||||
let ext_configs = agent.get_extension_configs().await;
|
||||
if ext_configs.iter().any(|c| c.name() == *server_name) {
|
||||
tracing::debug!("[AsterAgent] MCP extension '{}' 已注册,跳过", server_name);
|
||||
success_count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(wrapper) = clients.get(server_name) else {
|
||||
tracing::warn!("[AsterAgent] MCP server '{}' 无连接包装器", server_name);
|
||||
fail_count += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(running_service) = wrapper.running_service_arc() else {
|
||||
tracing::warn!("[AsterAgent] MCP server '{}' 无运行中 service", server_name);
|
||||
fail_count += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let server_tools = tools_by_server
|
||||
.get(server_name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let surface = build_mcp_extension_surface(
|
||||
server_name,
|
||||
format!("Lime MCP Bridge: {server_name}"),
|
||||
&server_tools,
|
||||
);
|
||||
|
||||
let extension = ExtensionConfig::Builtin {
|
||||
name: server_name.clone(),
|
||||
display_name: Some(server_name.clone()),
|
||||
description: surface.description.clone(),
|
||||
timeout: None,
|
||||
bundled: Some(false),
|
||||
available_tools: surface.available_tools.clone(),
|
||||
deferred_loading: surface.deferred_loading,
|
||||
always_expose_tools: surface.always_expose_tools.clone(),
|
||||
allowed_caller: surface.allowed_caller.clone(),
|
||||
};
|
||||
|
||||
let bridge_client = McpBridgeClient::new(
|
||||
server_name.clone(),
|
||||
running_service.clone(),
|
||||
wrapper.handler(),
|
||||
running_service.peer_info().cloned(),
|
||||
);
|
||||
let client: Arc<tokio::sync::Mutex<Box<dyn aster::agents::mcp_client::McpClientTrait>>> =
|
||||
Arc::new(tokio::sync::Mutex::new(Box::new(bridge_client)));
|
||||
|
||||
agent
|
||||
.extension_manager
|
||||
.add_client(
|
||||
server_name.clone(),
|
||||
extension,
|
||||
client,
|
||||
running_service.peer_info().cloned(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
"[AsterAgent] 已桥接 MCP extension: name={}, tool_count={}, deferred={}, always_expose={}",
|
||||
server_name,
|
||||
surface.available_tools.len(),
|
||||
surface.deferred_loading,
|
||||
surface.always_expose_tools.len()
|
||||
);
|
||||
success_count += 1;
|
||||
}
|
||||
|
||||
drop(clients);
|
||||
|
||||
if fail_count > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] MCP 注入结果: {} 成功, {} 失败",
|
||||
success_count,
|
||||
fail_count
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"[AsterAgent] MCP 注入完成: {} 个 extension 全部成功",
|
||||
success_count
|
||||
);
|
||||
}
|
||||
|
||||
(success_count, fail_count)
|
||||
}
|
||||
|
||||
/// 确保 Lime 可用的 MCP servers 已启动
|
||||
///
|
||||
/// 启动启用了 `enabled_lime` 的服务器。
|
||||
pub(crate) async fn ensure_lime_mcp_servers_running(
|
||||
db: &DbConnection,
|
||||
mcp_manager: &McpManagerState,
|
||||
) -> (usize, usize) {
|
||||
let servers = match McpService::get_all(db) {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
tracing::warn!("[AsterAgent] 读取 MCP 配置失败,跳过自动启动: {}", e);
|
||||
return (0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
if servers.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let candidates: Vec<&crate::models::mcp_model::McpServer> =
|
||||
servers.iter().filter(|s| s.enabled_lime).collect();
|
||||
|
||||
if candidates.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let manager = mcp_manager.lock().await;
|
||||
let mut success_count = 0usize;
|
||||
let mut fail_count = 0usize;
|
||||
|
||||
for server in candidates {
|
||||
if manager.is_server_running(&server.name).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed = server.parse_config();
|
||||
let config = McpServerConfig {
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
env: parsed.env,
|
||||
cwd: parsed.cwd,
|
||||
timeout: parsed.timeout,
|
||||
};
|
||||
|
||||
match manager.start_server(&server.name, &config).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[AsterAgent] MCP server 已自动启动: {}", server.name);
|
||||
success_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"[AsterAgent] MCP server 自动启动失败: {} => {}",
|
||||
server.name,
|
||||
e
|
||||
);
|
||||
fail_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(success_count, fail_count)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
//! Aster Agent 命令模块
|
||||
//!
|
||||
//! 提供基于 Aster 框架的 Tauri 命令
|
||||
//! 这是新的对话系统实现,与 native_agent_cmd.rs 并行存在
|
||||
//! 支持从 Lime 凭证池自动选择凭证
|
||||
|
||||
use crate::agent::aster_state::{ProviderConfig, SessionConfigBuilder};
|
||||
use crate::agent::runtime_queue_service::{
|
||||
clear_runtime_queue as clear_runtime_queue_service,
|
||||
list_runtime_queue_snapshots as list_runtime_queue_snapshots_service,
|
||||
promote_runtime_queued_turn as promote_runtime_queued_turn_service,
|
||||
remove_runtime_queued_turn as remove_runtime_queued_turn_service,
|
||||
resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_service,
|
||||
resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_service,
|
||||
submit_runtime_turn as submit_runtime_turn_service, RuntimeQueueExecutor,
|
||||
};
|
||||
use crate::agent::{
|
||||
AsterAgentState, AsterAgentWrapper, QueuedTurnSnapshot, QueuedTurnTask, SessionDetail,
|
||||
SessionInfo, SubAgentRole, TauriAgentEvent,
|
||||
};
|
||||
use crate::agent_tools::catalog::{
|
||||
browser_runtime_tool_prefix, build_mcp_extension_surface, creator_tool_names,
|
||||
WorkspaceToolSurface, LIME_CREATE_BROADCAST_TASK_TOOL_NAME, LIME_CREATE_COVER_TASK_TOOL_NAME,
|
||||
LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME,
|
||||
LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME,
|
||||
LIME_CREATE_VIDEO_TASK_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::agent_tools::execution::build_workspace_shell_allow_pattern;
|
||||
use crate::agent_tools::execution::{
|
||||
build_workspace_execution_permissions, should_auto_approve_tool_warnings,
|
||||
ToolExecutionResolverInput, WorkspaceExecutionPermissionInput,
|
||||
};
|
||||
use crate::agent_tools::inventory::{
|
||||
build_tool_inventory, resolve_extension_tool_runtime_status, AgentToolInventoryBuildInput,
|
||||
ExtensionToolInventorySeed,
|
||||
};
|
||||
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
|
||||
use crate::commands::webview_cmd::{
|
||||
browser_execute_action_global, ensure_managed_chrome_profile_global, BrowserActionRequest,
|
||||
BrowserBackendType,
|
||||
};
|
||||
use crate::config::{GlobalConfigManager, GlobalConfigManagerState};
|
||||
use crate::database::DbConnection;
|
||||
use crate::mcp::{McpManagerState, McpServerConfig};
|
||||
use crate::services::agent_timeline_service::AgentTimelineRecorder;
|
||||
use crate::services::automation_service::AutomationServiceState;
|
||||
use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource};
|
||||
use crate::services::memory_profile_prompt_service::{
|
||||
merge_system_prompt_with_memory_context, MemoryPromptContext,
|
||||
};
|
||||
use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search;
|
||||
use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
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::{Message, MessageContent};
|
||||
use aster::permission::{
|
||||
ConditionOperator, ConditionType, PermissionCondition, PermissionScope, ToolPermission,
|
||||
ToolPermissionManager,
|
||||
};
|
||||
use aster::permission::{Permission, PermissionConfirmation, PrincipalType};
|
||||
use aster::sandbox::{
|
||||
detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig,
|
||||
};
|
||||
use aster::session::extension_data::{ExtensionData, ExtensionState};
|
||||
use aster::session::{
|
||||
list_subagent_child_sessions, require_shared_thread_runtime_store,
|
||||
resolve_subagent_session_metadata, SessionManager, SessionType, SubagentSessionMetadata,
|
||||
};
|
||||
use aster::tools::task_output_tool::TaskOutputInput;
|
||||
use aster::tools::{
|
||||
BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager,
|
||||
TaskOutputTool, TaskTool, Tool, ToolContext, ToolError, ToolOptions, ToolResult,
|
||||
MAX_OUTPUT_LENGTH,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use lime_agent::event_converter::{TauriMessage, TauriMessageContent};
|
||||
use lime_agent::mcp_bridge::McpBridgeClient;
|
||||
#[cfg(test)]
|
||||
use lime_agent::request_tool_policy::REQUEST_TOOL_POLICY_MARKER;
|
||||
use lime_agent::request_tool_policy::{
|
||||
merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy_with_mode,
|
||||
stream_message_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode,
|
||||
};
|
||||
use lime_agent::{
|
||||
build_subagent_customization_prompt, builtin_profile_descriptor_by_id,
|
||||
builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id, is_virtual_memory_path,
|
||||
list_subagent_cascade_session_ids, load_subagent_runtime_status,
|
||||
merge_system_prompt_with_runtime_agents, message_suggests_news_expansion,
|
||||
read_subagent_control_state, resolve_virtual_memory_path, summarize_builtin_skill,
|
||||
virtual_memory_relative_path, write_subagent_control_state, ProviderContinuationCapability,
|
||||
ProviderContinuationCapable, ProviderContinuationState, RuntimeProjectionSnapshot,
|
||||
SessionStateSnapshot, SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus,
|
||||
SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus,
|
||||
TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot,
|
||||
TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, DURABLE_MEMORY_VIRTUAL_ROOT,
|
||||
};
|
||||
use lime_services::api_key_provider_service::ApiKeyProviderService;
|
||||
use lime_services::mcp_service::McpService;
|
||||
use lime_services::video_generation_service::{
|
||||
CreateVideoGenerationRequest, VideoGenerationService,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_BASH_TIMEOUT_SECS: u64 = 300;
|
||||
const MAX_BASH_TIMEOUT_SECS: u64 = 1800;
|
||||
const CODE_EXECUTION_EXTENSION_NAME: &str = "code_execution";
|
||||
const WORKSPACE_SANDBOX_ENABLED_ENV_KEYS: &[&str] = &[
|
||||
"LIME_WORKSPACE_SANDBOX_ENABLED",
|
||||
"PROXYCAST_WORKSPACE_SANDBOX_ENABLED",
|
||||
];
|
||||
const WORKSPACE_SANDBOX_STRICT_ENV_KEYS: &[&str] = &[
|
||||
"LIME_WORKSPACE_SANDBOX_STRICT",
|
||||
"PROXYCAST_WORKSPACE_SANDBOX_STRICT",
|
||||
];
|
||||
const WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS: &[&str] = &[
|
||||
"LIME_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK",
|
||||
"PROXYCAST_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK",
|
||||
];
|
||||
const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback";
|
||||
const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created";
|
||||
const DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS: usize = 3;
|
||||
const SOCIAL_IMAGE_DEFAULT_MODEL: &str = "gemini-3-pro-image-preview";
|
||||
const SOCIAL_IMAGE_DEFAULT_SIZE: &str = "1024x1024";
|
||||
const SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT: &str = "url";
|
||||
const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】";
|
||||
const ELICITATION_CONTEXT_PROMPT_MARKER: &str = "【已收集的补充信息】";
|
||||
const TEAM_PREFERENCE_PROMPT_MARKER: &str = "【Team 协作偏好】";
|
||||
const LIME_TOOL_METADATA_BEGIN: &str = "[Lime 工具元数据开始]";
|
||||
const LIME_TOOL_METADATA_END: &str = "[Lime 工具元数据结束]";
|
||||
const FORCE_REACT_HINT_ENV_KEYS: &[&str] =
|
||||
&["LIME_FORCE_REACT_HINTS", "PROXYCAST_FORCE_REACT_HINTS"];
|
||||
const CODE_ORCHESTRATED_HINT_ENV_KEYS: &[&str] = &[
|
||||
"LIME_CODE_ORCHESTRATED_HINTS",
|
||||
"PROXYCAST_CODE_ORCHESTRATED_HINTS",
|
||||
];
|
||||
|
||||
static SHARED_TASK_MANAGER: OnceLock<Arc<TaskManager>> = OnceLock::new();
|
||||
|
||||
fn shared_task_manager() -> Arc<TaskManager> {
|
||||
SHARED_TASK_MANAGER
|
||||
.get_or_init(|| Arc::new(TaskManager::new()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct WorkspaceSandboxPolicy {
|
||||
enabled: bool,
|
||||
strict: bool,
|
||||
notify_on_fallback: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum WorkspaceSandboxApplyOutcome {
|
||||
Applied {
|
||||
sandbox_type: String,
|
||||
},
|
||||
DisabledByConfig,
|
||||
UnavailableFallback {
|
||||
warning_message: String,
|
||||
notify_user: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn parse_bool_env(names: &[&str]) -> Option<bool> {
|
||||
lime_core::env_compat::bool_var(names)
|
||||
}
|
||||
|
||||
fn resolve_workspace_sandbox_policy(
|
||||
config_manager: &GlobalConfigManagerState,
|
||||
) -> WorkspaceSandboxPolicy {
|
||||
let config = config_manager.config();
|
||||
let mut policy = WorkspaceSandboxPolicy {
|
||||
enabled: config.agent.workspace_sandbox.enabled,
|
||||
strict: config.agent.workspace_sandbox.strict,
|
||||
notify_on_fallback: config.agent.workspace_sandbox.notify_on_fallback,
|
||||
};
|
||||
|
||||
if let Some(enabled) = parse_bool_env(WORKSPACE_SANDBOX_ENABLED_ENV_KEYS) {
|
||||
policy.enabled = enabled;
|
||||
}
|
||||
if let Some(strict) = parse_bool_env(WORKSPACE_SANDBOX_STRICT_ENV_KEYS) {
|
||||
policy.strict = strict;
|
||||
}
|
||||
if let Some(notify) = parse_bool_env(WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS) {
|
||||
policy.notify_on_fallback = notify;
|
||||
}
|
||||
|
||||
policy
|
||||
}
|
||||
|
||||
fn workspace_sandbox_platform_hint() -> &'static str {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
"Windows 当前未检测到可用本地 sandbox 执行器,建议关闭该选项或使用非严格模式。"
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
"macOS 需提供 sandbox-exec。"
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
"Linux 需安装 bwrap 或 firejail。"
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
"当前平台暂未集成本地 sandbox 执行器,建议关闭该选项。"
|
||||
}
|
||||
}
|
||||
|
||||
fn build_workspace_sandbox_warning_message(reason: &str) -> String {
|
||||
format!("已启用 workspace 本地 sandbox,但当前环境不可用,已自动降级为普通执行。原因: {reason}")
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str, field_name: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
Err(format!("{field_name} 不能为空"))
|
||||
} else {
|
||||
Ok(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
let trimmed = value?.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod action_runtime;
|
||||
mod browser_assist;
|
||||
pub(crate) mod command_api;
|
||||
mod dto;
|
||||
mod mcp_bridge;
|
||||
mod prompt_context;
|
||||
mod reply_runtime;
|
||||
mod run_metadata;
|
||||
mod runtime_turn;
|
||||
mod session_runtime;
|
||||
mod subagent_runtime;
|
||||
pub(crate) mod tool_runtime;
|
||||
#[cfg(test)]
|
||||
use self::subagent_runtime::{
|
||||
build_subagent_customization_state, build_subagent_customization_system_prompt,
|
||||
subagent_counts_toward_team_limit,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::tool_runtime::{
|
||||
encode_tool_result_for_harness_observability, normalize_params_for_durable_memory_support,
|
||||
normalize_shell_command_params, normalize_workspace_tool_permission_behavior,
|
||||
};
|
||||
#[cfg(test)]
|
||||
include!("tests.rs");
|
||||
|
||||
pub(crate) use action_runtime::{agent_runtime_delete_session, agent_runtime_respond_action};
|
||||
#[cfg(test)]
|
||||
pub(crate) use action_runtime::{
|
||||
build_action_resume_runtime_status, build_runtime_action_user_data,
|
||||
validate_elicitation_submission,
|
||||
};
|
||||
pub(crate) use browser_assist::{
|
||||
append_browser_assist_session_permissions, apply_browser_requirement_to_request_tool_policy,
|
||||
default_web_search_enabled_for_chat_mode, extract_browser_task_requirement,
|
||||
get_browser_assist_runtime_hint, is_browser_assist_enabled, parse_browser_backend_hint,
|
||||
resolve_runtime_chat_mode, runtime_chat_mode_label, should_enable_model_skill_tool,
|
||||
sync_browser_assist_runtime_hint, BrowserAssistRuntimeHint, BrowserTaskRequirement,
|
||||
RuntimeChatMode, BROWSER_PROFILE_KEY_ENV_KEYS,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use browser_assist::{
|
||||
extract_browser_assist_runtime_hint, BROWSER_ASSIST_ALLOW_PATTERN,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use command_api::{
|
||||
agent_runtime_close_subagent, agent_runtime_create_session, agent_runtime_get_session,
|
||||
agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn, agent_runtime_list_sessions,
|
||||
agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn,
|
||||
agent_runtime_resume_subagent, agent_runtime_send_subagent_input, agent_runtime_spawn_subagent,
|
||||
agent_runtime_submit_turn, agent_runtime_update_session, agent_runtime_wait_subagents,
|
||||
aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init,
|
||||
aster_agent_reset, aster_agent_status,
|
||||
};
|
||||
pub(crate) use dto::{
|
||||
AgentRuntimeActionType, AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse,
|
||||
AgentRuntimeInterruptTurnRequest, AgentRuntimePromoteQueuedTurnRequest,
|
||||
AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeRespondActionRequest,
|
||||
AgentRuntimeResumeSubagentRequest, AgentRuntimeResumeSubagentResponse,
|
||||
AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse,
|
||||
AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse,
|
||||
AgentRuntimeSubmitTurnRequest, AgentRuntimeToolInventoryRequest,
|
||||
AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest,
|
||||
AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload,
|
||||
ConfigureFromPoolRequest, ConfigureProviderRequest,
|
||||
};
|
||||
pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions};
|
||||
#[cfg(test)]
|
||||
pub(crate) use prompt_context::build_team_preference_system_prompt;
|
||||
pub(crate) use prompt_context::{
|
||||
merge_system_prompt_with_auto_continue, merge_system_prompt_with_elicitation_context,
|
||||
merge_system_prompt_with_team_preference,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use reply_runtime::message_suggests_live_search;
|
||||
use reply_runtime::{
|
||||
build_runtime_user_message, build_turn_runtime_statuses, complete_runtime_status_projection,
|
||||
emit_runtime_status_with_projection, ensure_code_execution_extension_enabled,
|
||||
should_fallback_to_react_from_code_orchestrated, stream_reply_once,
|
||||
};
|
||||
use run_metadata::{
|
||||
build_chat_run_finish_metadata, build_chat_run_metadata_base, extract_harness_array,
|
||||
extract_harness_bool, extract_harness_nested_object, extract_harness_string,
|
||||
load_previous_provider_continuation_state, ChatRunObservation,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use run_metadata::{
|
||||
extract_artifact_path_from_tool_start, provider_routing_matches_current,
|
||||
resolve_social_run_artifact_descriptor,
|
||||
};
|
||||
pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor};
|
||||
pub(crate) use session_runtime::{
|
||||
delete_runtime_session_internal, persist_session_provider_routing,
|
||||
resolve_session_provider_selector,
|
||||
};
|
||||
pub(crate) use subagent_runtime::{
|
||||
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
|
||||
agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal,
|
||||
agent_runtime_wait_subagents_internal, emit_subagent_status_changed_events,
|
||||
maybe_emit_subagent_status_for_runtime_event, SubagentControlRuntime,
|
||||
};
|
||||
#[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_social_image_tool_registered, ensure_tool_search_tool_registered,
|
||||
};
|
||||
|
||||
pub async fn resume_persisted_runtime_queues_on_startup(
|
||||
app: AppHandle,
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
api_key_provider_service: &ApiKeyProviderServiceState,
|
||||
logs: &LogState,
|
||||
config_manager: &GlobalConfigManagerState,
|
||||
mcp_manager: &McpManagerState,
|
||||
automation_state: &AutomationServiceState,
|
||||
) -> Result<usize, String> {
|
||||
resume_persisted_runtime_queues_on_startup_service(
|
||||
app,
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Agent 执行策略
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum AsterExecutionStrategy {
|
||||
React,
|
||||
CodeOrchestrated,
|
||||
#[default]
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl AsterExecutionStrategy {
|
||||
fn as_db_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::React => "react",
|
||||
Self::CodeOrchestrated => "code_orchestrated",
|
||||
Self::Auto => "auto",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_db_value(value: Option<&str>) -> Self {
|
||||
match value {
|
||||
Some("code_orchestrated") => Self::CodeOrchestrated,
|
||||
Some("auto") => Self::Auto,
|
||||
_ => Self::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_for_message(self, message: &str) -> Self {
|
||||
if should_force_react_for_message(message) {
|
||||
return Self::React;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Auto if should_use_code_orchestrated_for_message(message) => {
|
||||
Self::CodeOrchestrated
|
||||
}
|
||||
Self::Auto => Self::React,
|
||||
_ => self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_force_react_for_message(message: &str) -> bool {
|
||||
let lowered = message.to_lowercase();
|
||||
let default_hints = [
|
||||
"tool_search",
|
||||
"调用 tool_search",
|
||||
"调用tool_search",
|
||||
"use tool_search",
|
||||
"call tool_search",
|
||||
"websearch",
|
||||
"web search",
|
||||
"web_search",
|
||||
"webfetch",
|
||||
"web fetch",
|
||||
"web_fetch",
|
||||
];
|
||||
resolve_intent_hints(FORCE_REACT_HINT_ENV_KEYS, &default_hints)
|
||||
.iter()
|
||||
.any(|kw| lowered.contains(kw))
|
||||
}
|
||||
|
||||
fn should_use_code_orchestrated_for_message(message: &str) -> bool {
|
||||
let lowered = message.to_lowercase();
|
||||
// 默认不做消息关键词硬编码推断,Auto 模式优先走 ReAct。
|
||||
// 如需启用自动切换,可通过环境变量 LIME_CODE_ORCHESTRATED_HINTS 显式配置。
|
||||
resolve_intent_hints(CODE_ORCHESTRATED_HINT_ENV_KEYS, &[])
|
||||
.iter()
|
||||
.any(|kw| lowered.contains(kw))
|
||||
}
|
||||
|
||||
fn resolve_intent_hints(env_keys: &[&str], defaults: &[&str]) -> Vec<String> {
|
||||
if let Some(raw) = lime_core::env_compat::var(env_keys) {
|
||||
let parsed = raw
|
||||
.split(',')
|
||||
.map(|item| item.trim().to_lowercase())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if !parsed.is_empty() {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
defaults.iter().map(|item| item.to_string()).collect()
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
use super::*;
|
||||
|
||||
fn build_auto_continue_system_prompt(config: &AutoContinuePayload) -> String {
|
||||
let mode_instruction = if config.fast_mode_enabled {
|
||||
"快速模式:优先产出可用结果,减少解释与冗余。"
|
||||
} else {
|
||||
"标准模式:兼顾可读性、完整性与发布可用性。"
|
||||
};
|
||||
let source = config
|
||||
.source
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("document_canvas");
|
||||
|
||||
format!(
|
||||
"{AUTO_CONTINUE_PROMPT_MARKER}\n\
|
||||
执行来源:{source}\n\
|
||||
执行要求:\n\
|
||||
1. 本轮任务是“基于已有文稿的续写”,不得重复已有内容。\n\
|
||||
2. 从现有结尾自然衔接,保持原文语气、受众和主题方向。\n\
|
||||
3. 续写长度:{}。\n\
|
||||
4. 灵敏度({}%):{}。\n\
|
||||
5. {}\n\
|
||||
6. 输出正文时不要显式提及你看到了该策略配置。",
|
||||
config.length_instruction(),
|
||||
config.sensitivity,
|
||||
config.sensitivity_instruction(),
|
||||
mode_instruction,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn merge_system_prompt_with_auto_continue(
|
||||
base_prompt: Option<String>,
|
||||
auto_continue: Option<&AutoContinuePayload>,
|
||||
) -> Option<String> {
|
||||
let Some(config) = auto_continue else {
|
||||
return base_prompt;
|
||||
};
|
||||
if !config.enabled {
|
||||
return base_prompt;
|
||||
}
|
||||
|
||||
let auto_continue_prompt = build_auto_continue_system_prompt(config);
|
||||
|
||||
match base_prompt {
|
||||
Some(base) => {
|
||||
if base.contains(AUTO_CONTINUE_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(auto_continue_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{auto_continue_prompt}"))
|
||||
}
|
||||
}
|
||||
None => Some(auto_continue_prompt),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_elicitation_context_system_prompt(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let metadata = request_metadata?.as_object()?;
|
||||
let context = metadata.get("elicitation_context")?.as_object()?;
|
||||
let entries = context.get("entries")?.as_array()?;
|
||||
|
||||
let rendered_entries = entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let entry_object = entry.as_object()?;
|
||||
let label = entry_object
|
||||
.get("label")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let summary = entry_object
|
||||
.get("summary")
|
||||
.or_else(|| entry_object.get("value"))
|
||||
.and_then(render_elicitation_context_value)?;
|
||||
Some(format!("- {label}: {summary}"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if rendered_entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let source = context
|
||||
.get("source")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("structured_form");
|
||||
let mode = context
|
||||
.get("mode")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("runtime_metadata");
|
||||
|
||||
Some(format!(
|
||||
"{ELICITATION_CONTEXT_PROMPT_MARKER}\n\
|
||||
来源:{source}\n\
|
||||
模式:{mode}\n\
|
||||
执行要求:\n\
|
||||
1. 下列信息来自用户刚刚提交的结构化补充信息,视为本轮已确认约束。\n\
|
||||
2. 回答与后续执行时优先吸收这些信息,不要重复追问同一字段。\n\
|
||||
3. 若仍缺关键信息,只追问尚未填写的最少字段。\n\
|
||||
已确认信息:\n\
|
||||
{}",
|
||||
rendered_entries.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
fn render_elicitation_context_value(value: &serde_json::Value) -> Option<String> {
|
||||
match value {
|
||||
serde_json::Value::String(text) => {
|
||||
let normalized = text.trim();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(number) => Some(number.to_string()),
|
||||
serde_json::Value::Bool(boolean) => Some(if *boolean {
|
||||
"是".to_string()
|
||||
} else {
|
||||
"否".to_string()
|
||||
}),
|
||||
serde_json::Value::Array(items) => {
|
||||
let rendered = items
|
||||
.iter()
|
||||
.filter_map(render_elicitation_context_value)
|
||||
.collect::<Vec<_>>();
|
||||
if rendered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(rendered.join("、"))
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(object) => {
|
||||
let rendered = serde_json::to_string(object).ok()?;
|
||||
let normalized = rendered.trim();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized.to_string())
|
||||
}
|
||||
}
|
||||
serde_json::Value::Null => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn merge_system_prompt_with_elicitation_context(
|
||||
base_prompt: Option<String>,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let Some(elicitation_prompt) = build_elicitation_context_system_prompt(request_metadata) else {
|
||||
return base_prompt;
|
||||
};
|
||||
|
||||
match base_prompt {
|
||||
Some(base) => {
|
||||
if base.contains(ELICITATION_CONTEXT_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(elicitation_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{elicitation_prompt}"))
|
||||
}
|
||||
}
|
||||
None => Some(elicitation_prompt),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_team_preference_system_prompt(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let subagent_mode_enabled = extract_harness_bool(
|
||||
request_metadata,
|
||||
&["subagent_mode_enabled", "subagentModeEnabled"],
|
||||
)
|
||||
.unwrap_or(false);
|
||||
let preferred_team_preset_id = extract_harness_string(
|
||||
request_metadata,
|
||||
&["preferred_team_preset_id", "preferredTeamPresetId"],
|
||||
);
|
||||
let selected_team_source = extract_harness_string(
|
||||
request_metadata,
|
||||
&["selected_team_source", "selectedTeamSource"],
|
||||
);
|
||||
let selected_team_label = extract_harness_string(
|
||||
request_metadata,
|
||||
&["selected_team_label", "selectedTeamLabel"],
|
||||
);
|
||||
let selected_team_summary = extract_harness_string(
|
||||
request_metadata,
|
||||
&["selected_team_summary", "selectedTeamSummary"],
|
||||
);
|
||||
let selected_team_roles = extract_harness_array(
|
||||
request_metadata,
|
||||
&["selected_team_roles", "selectedTeamRoles"],
|
||||
);
|
||||
|
||||
if !subagent_mode_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lines = vec![TEAM_PREFERENCE_PROMPT_MARKER.to_string()];
|
||||
if subagent_mode_enabled {
|
||||
lines.push(
|
||||
"- 当前 GUI 已开启 Team 模式,但只有在任务确实适合拆分、并行或隔离上下文时才进入 team。"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(team_preset_id) = preferred_team_preset_id.as_deref() {
|
||||
let preset_label =
|
||||
builtin_team_preset_label_by_id(team_preset_id).unwrap_or(team_preset_id);
|
||||
lines.push(format!(
|
||||
"- 用户偏好的 Team Preset:{preset_label} ({team_preset_id})。"
|
||||
));
|
||||
lines.push(
|
||||
"- 当你判断当前任务适合多代理时,优先沿用该 preset 的 profile / skill 组合去调用 spawn_agent。"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(team_label) = selected_team_label.as_deref() {
|
||||
let source_suffix = selected_team_source
|
||||
.as_deref()
|
||||
.map(|source| format!(" / 来源:{source}"))
|
||||
.unwrap_or_default();
|
||||
lines.push(format!(
|
||||
"- 当前 GUI 已选 Team:{team_label}{source_suffix}。"
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(team_summary) = selected_team_summary.as_deref() {
|
||||
lines.push(format!("- Team 摘要:{team_summary}"));
|
||||
}
|
||||
|
||||
if let Some(role_items) = selected_team_roles {
|
||||
let rendered_roles = role_items
|
||||
.iter()
|
||||
.filter_map(|value| {
|
||||
let object = value.as_object()?;
|
||||
let label = object
|
||||
.get("label")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let summary = object
|
||||
.get("summary")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("负责当前分工。");
|
||||
let profile_suffix = object
|
||||
.get("profile_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!(" / profile: {value}"))
|
||||
.unwrap_or_default();
|
||||
let role_key_suffix = object
|
||||
.get("role_key")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!(" / roleKey: {value}"))
|
||||
.unwrap_or_default();
|
||||
let skill_suffix = object
|
||||
.get("skill_ids")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(|items| format!(" / skills: {}", items.join(", ")))
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(format!(
|
||||
" - {label}:{summary}{profile_suffix}{role_key_suffix}{skill_suffix}"
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !rendered_roles.is_empty() {
|
||||
lines.push("- 当前 Team 角色参考:".to_string());
|
||||
lines.extend(rendered_roles);
|
||||
lines.push(
|
||||
"- 如果你决定调用 spawn_agent,请优先把上述 profile / roleKey / skillIds 映射到对应结构化字段,保持 GUI Team 画布与实际分工一致。"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"- spawn_agent 支持这些结构化字段:teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。"
|
||||
.to_string(),
|
||||
);
|
||||
lines.push(
|
||||
"- 如果任务简单、强依赖当前上下文或下一步立即阻塞在结果上,不要为了套用 preset 而滥用 team。"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn merge_system_prompt_with_team_preference(
|
||||
base_prompt: Option<String>,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let Some(team_prompt) = build_team_preference_system_prompt(request_metadata) else {
|
||||
return base_prompt;
|
||||
};
|
||||
|
||||
match base_prompt {
|
||||
Some(base) => {
|
||||
if base.contains(TEAM_PREFERENCE_PROMPT_MARKER) {
|
||||
Some(base)
|
||||
} else if base.trim().is_empty() {
|
||||
Some(team_prompt)
|
||||
} else {
|
||||
Some(format!("{base}\n\n{team_prompt}"))
|
||||
}
|
||||
}
|
||||
None => Some(team_prompt),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
use super::*;
|
||||
|
||||
fn execution_strategy_label(strategy: AsterExecutionStrategy) -> &'static str {
|
||||
match strategy {
|
||||
AsterExecutionStrategy::React => "对话执行优先",
|
||||
AsterExecutionStrategy::CodeOrchestrated => "代码编排执行",
|
||||
AsterExecutionStrategy::Auto => "自动路由执行",
|
||||
}
|
||||
}
|
||||
|
||||
fn model_supports_reasoning(model_name: Option<&str>) -> bool {
|
||||
let Some(model_name) = model_name.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
let normalized = model_name.to_ascii_lowercase();
|
||||
normalized.contains("thinking")
|
||||
|| normalized.contains("reason")
|
||||
|| normalized.contains("r1")
|
||||
|| normalized.contains("o1")
|
||||
|| normalized.contains("o3")
|
||||
|| normalized.contains("o4")
|
||||
|| normalized.contains("gpt-5")
|
||||
|| normalized.contains("2.5")
|
||||
}
|
||||
|
||||
pub(super) fn message_suggests_live_search(message: &str) -> bool {
|
||||
let normalized = message.to_ascii_lowercase();
|
||||
[
|
||||
"搜索",
|
||||
"搜一下",
|
||||
"查一下",
|
||||
"查一查",
|
||||
"检索",
|
||||
"上网查",
|
||||
"联网查",
|
||||
"最新",
|
||||
"今天",
|
||||
"刚刚",
|
||||
"实时",
|
||||
"新闻",
|
||||
"股价",
|
||||
"汇率",
|
||||
"天气",
|
||||
"政策",
|
||||
"法规",
|
||||
"版本",
|
||||
"价格",
|
||||
"热搜",
|
||||
"上线",
|
||||
"发布",
|
||||
"search",
|
||||
"look up",
|
||||
"google",
|
||||
"browse",
|
||||
"now",
|
||||
"today",
|
||||
"latest",
|
||||
"recent",
|
||||
"price",
|
||||
"version",
|
||||
"news",
|
||||
"weather",
|
||||
]
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
fn message_suggests_planning(message: &str) -> bool {
|
||||
let normalized = message.to_ascii_lowercase();
|
||||
[
|
||||
"计划",
|
||||
"规划",
|
||||
"roadmap",
|
||||
"拆解",
|
||||
"分步骤",
|
||||
"执行方案",
|
||||
"实施方案",
|
||||
"阶段",
|
||||
"里程碑",
|
||||
"todo",
|
||||
]
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
fn message_suggests_task(message: &str) -> bool {
|
||||
let normalized = message.to_ascii_lowercase();
|
||||
[
|
||||
"后台",
|
||||
"稍后",
|
||||
"异步",
|
||||
"排队",
|
||||
"持续生成",
|
||||
"长时间",
|
||||
"继续跑",
|
||||
"持续跑",
|
||||
]
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
fn message_suggests_subagent(message: &str) -> bool {
|
||||
let normalized = message.to_ascii_lowercase();
|
||||
[
|
||||
"并行",
|
||||
"多代理",
|
||||
"分工",
|
||||
"分别分析",
|
||||
"从多个角度",
|
||||
"parallel",
|
||||
"subagent",
|
||||
"delegate",
|
||||
]
|
||||
.iter()
|
||||
.any(|keyword| normalized.contains(keyword))
|
||||
}
|
||||
|
||||
pub(super) fn build_turn_runtime_statuses(
|
||||
request: &AsterChatRequest,
|
||||
effective_strategy: AsterExecutionStrategy,
|
||||
request_tool_policy: &RequestToolPolicy,
|
||||
model_name: Option<&str>,
|
||||
) -> (TauriRuntimeStatus, TauriRuntimeStatus) {
|
||||
let thinking_enabled = extract_harness_bool(
|
||||
request.metadata.as_ref(),
|
||||
&["thinking_enabled", "thinkingEnabled"],
|
||||
)
|
||||
.unwrap_or(false);
|
||||
let task_enabled = extract_harness_bool(
|
||||
request.metadata.as_ref(),
|
||||
&["task_mode_enabled", "taskModeEnabled"],
|
||||
)
|
||||
.unwrap_or(false);
|
||||
let subagent_enabled = extract_harness_bool(
|
||||
request.metadata.as_ref(),
|
||||
&["subagent_mode_enabled", "subagentModeEnabled"],
|
||||
)
|
||||
.unwrap_or(false);
|
||||
let reasoning_supported = model_supports_reasoning(model_name);
|
||||
let news_expansion_needed = request_tool_policy.allows_web_search()
|
||||
&& message_suggests_news_expansion(&request.message);
|
||||
let browser_task_requirement = extract_browser_task_requirement(request.metadata.as_ref());
|
||||
|
||||
let initial_checkpoints = vec![
|
||||
execution_strategy_label(effective_strategy).to_string(),
|
||||
if request_tool_policy.requires_web_search() {
|
||||
"本回合必须先联网核实".to_string()
|
||||
} else if news_expansion_needed {
|
||||
"已识别新闻综述类输入,将先并发 WebSearch 扩搜".to_string()
|
||||
} else if request_tool_policy.allows_web_search() {
|
||||
"联网搜索仅作为候选能力待命".to_string()
|
||||
} else {
|
||||
"默认直接回答优先".to_string()
|
||||
},
|
||||
if matches!(
|
||||
browser_task_requirement,
|
||||
Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep)
|
||||
) {
|
||||
"当前任务要求真实浏览器执行,不允许退化为联网检索".to_string()
|
||||
} else {
|
||||
"浏览器能力按需升级".to_string()
|
||||
},
|
||||
if thinking_enabled && reasoning_supported {
|
||||
"模型支持深度思考,先进入推理判定".to_string()
|
||||
} else if thinking_enabled {
|
||||
"当前模型不支持显式 thinking,改走轻量意图理解".to_string()
|
||||
} else {
|
||||
"先做轻量意图理解".to_string()
|
||||
},
|
||||
if task_enabled {
|
||||
"后台任务能力已待命".to_string()
|
||||
} else {
|
||||
"默认不升级后台任务".to_string()
|
||||
},
|
||||
if subagent_enabled {
|
||||
"多代理能力已待命".to_string()
|
||||
} else {
|
||||
"默认由单 Agent 先判断".to_string()
|
||||
},
|
||||
];
|
||||
|
||||
let decided = if request_tool_policy.requires_web_search() {
|
||||
(
|
||||
"已决定:先联网检索".to_string(),
|
||||
"当前回合已被明确指定为先搜索后答复,会先完成联网核实再继续生成。".to_string(),
|
||||
vec![
|
||||
"用户明确要求联网搜索".to_string(),
|
||||
"搜索结果返回后再形成最终答复".to_string(),
|
||||
],
|
||||
)
|
||||
} else if news_expansion_needed {
|
||||
(
|
||||
"已决定:先联网扩搜".to_string(),
|
||||
"当前输入属于新闻/最新动态综述类请求,会先并发执行多组 WebSearch,再基于结果做主题聚类与交叉验证。"
|
||||
.to_string(),
|
||||
vec![
|
||||
"统一使用 WebSearch 执行多组扩搜".to_string(),
|
||||
"完成来源整合后再组织最终答复".to_string(),
|
||||
],
|
||||
)
|
||||
} else if subagent_enabled && message_suggests_subagent(&request.message) {
|
||||
(
|
||||
"已决定:优先拆分为多代理".to_string(),
|
||||
"用户输入更适合并行分工处理,先按多代理路径组织执行。".to_string(),
|
||||
vec![
|
||||
"检测到并行/多角度需求".to_string(),
|
||||
"主线程先承担协调职责".to_string(),
|
||||
],
|
||||
)
|
||||
} else if task_enabled && message_suggests_task(&request.message) {
|
||||
(
|
||||
"已决定:升级为后台任务".to_string(),
|
||||
"用户输入更接近耗时或异步推进场景,优先走后台任务链路。".to_string(),
|
||||
vec![
|
||||
"检测到排队/持续执行诉求".to_string(),
|
||||
"先建立任务,再回传过程与产出".to_string(),
|
||||
],
|
||||
)
|
||||
} else if thinking_enabled && reasoning_supported {
|
||||
(
|
||||
"已决定:先深度思考".to_string(),
|
||||
"当前模型支持 reasoning,先做更充分的意图理解与方案判断,再决定是否调用搜索或工具。"
|
||||
.to_string(),
|
||||
vec![
|
||||
"thinking 已开启".to_string(),
|
||||
"搜索与工具保持候选状态,不默认触发".to_string(),
|
||||
],
|
||||
)
|
||||
} else if thinking_enabled {
|
||||
(
|
||||
"已决定:轻量理解后回答".to_string(),
|
||||
"当前模型不支持显式 reasoning,先做轻量意图理解,再决定是否需要搜索或其他能力。"
|
||||
.to_string(),
|
||||
vec![
|
||||
"thinking 已开启".to_string(),
|
||||
"当前模型回退为轻量推理".to_string(),
|
||||
],
|
||||
)
|
||||
} else if request_tool_policy.allows_web_search()
|
||||
&& message_suggests_live_search(&request.message)
|
||||
{
|
||||
(
|
||||
"已决定:先联网核实".to_string(),
|
||||
"问题包含明显时效性或实时性特征,先搜索核实再回答更稳妥。".to_string(),
|
||||
vec![
|
||||
"已检测到最新/实时信息需求".to_string(),
|
||||
"搜索完成后继续组织答复".to_string(),
|
||||
],
|
||||
)
|
||||
} else if message_suggests_planning(&request.message) {
|
||||
(
|
||||
"已决定:先规划再输出".to_string(),
|
||||
"当前请求更像计划或方案拆解,会先整理执行路径和关键步骤。".to_string(),
|
||||
vec![
|
||||
"检测到计划/拆解需求".to_string(),
|
||||
"优先输出结构化行动路径".to_string(),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"已决定:直接回答优先".to_string(),
|
||||
"当前请求无需默认升级为搜索或任务,先直接给出结果,必要时再调用工具。".to_string(),
|
||||
vec![
|
||||
"默认保持单回合直接回答".to_string(),
|
||||
"只有证据不足或时效性要求出现时才升级".to_string(),
|
||||
],
|
||||
)
|
||||
};
|
||||
|
||||
(
|
||||
TauriRuntimeStatus {
|
||||
phase: "preparing".to_string(),
|
||||
title: "正在理解意图".to_string(),
|
||||
detail:
|
||||
"正在判断当前回合应该直接回答、深度思考、规划、联网核实,还是升级为任务/多代理。"
|
||||
.to_string(),
|
||||
checkpoints: initial_checkpoints,
|
||||
},
|
||||
TauriRuntimeStatus {
|
||||
phase: "routing".to_string(),
|
||||
title: decided.0,
|
||||
detail: decided.1,
|
||||
checkpoints: decided.2,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn emit_projected_runtime_item_event(
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
|
||||
workspace_root: &str,
|
||||
event: TauriAgentEvent,
|
||||
) {
|
||||
if let Err(error) = app.emit(event_name, &event) {
|
||||
tracing::warn!("[AsterAgent] 发送 runtime item 投影事件失败: {}", error);
|
||||
}
|
||||
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.record_runtime_event(app, event_name, &event, workspace_root) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录 runtime item 投影事件失败(已降级继续): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn emit_runtime_status_with_projection(
|
||||
agent: &Agent,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
|
||||
workspace_root: &str,
|
||||
session_config: &aster::agents::SessionConfig,
|
||||
status: TauriRuntimeStatus,
|
||||
) {
|
||||
match agent
|
||||
.upsert_runtime_status_item(
|
||||
session_config,
|
||||
status.phase.clone(),
|
||||
status.title.clone(),
|
||||
status.detail.clone(),
|
||||
status.checkpoints.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(agent_event) => {
|
||||
for event in lime_agent::convert_agent_event(agent_event) {
|
||||
emit_projected_runtime_item_event(
|
||||
app,
|
||||
event_name,
|
||||
timeline_recorder,
|
||||
workspace_root,
|
||||
event,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 写入 runtime_status item 失败,降级仅发送 transient 事件: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let runtime_event = TauriAgentEvent::RuntimeStatus { status };
|
||||
if let Err(error) = app.emit(event_name, &runtime_event) {
|
||||
tracing::warn!("[AsterAgent] 发送 runtime_status 失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn complete_runtime_status_projection(
|
||||
agent: &Agent,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
|
||||
workspace_root: &str,
|
||||
session_config: &aster::agents::SessionConfig,
|
||||
) {
|
||||
match agent.complete_runtime_status_item(session_config).await {
|
||||
Ok(Some(agent_event)) => {
|
||||
for event in lime_agent::convert_agent_event(agent_event) {
|
||||
emit_projected_runtime_item_event(
|
||||
app,
|
||||
event_name,
|
||||
timeline_recorder,
|
||||
workspace_root,
|
||||
event,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::warn!("[AsterAgent] 完成 runtime_status item 失败: {}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_fallback_to_react_from_code_orchestrated(error: &ReplyAttemptError) -> bool {
|
||||
if !error.emitted_any {
|
||||
return true;
|
||||
}
|
||||
|
||||
let lowered = error.message.to_lowercase();
|
||||
let recoverable_hints = ["unknown subscript", "tool_search_analysis", "web_scraping"];
|
||||
|
||||
recoverable_hints.iter().any(|hint| lowered.contains(hint))
|
||||
}
|
||||
|
||||
pub(super) async fn ensure_code_execution_extension_enabled(agent: &Agent) -> Result<bool, String> {
|
||||
let extension_configs = agent.get_extension_configs().await;
|
||||
if extension_configs
|
||||
.iter()
|
||||
.any(|cfg| cfg.name() == CODE_EXECUTION_EXTENSION_NAME)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let extension = ExtensionConfig::Platform {
|
||||
name: CODE_EXECUTION_EXTENSION_NAME.to_string(),
|
||||
description: "Execute JavaScript code in a sandboxed environment".to_string(),
|
||||
bundled: Some(true),
|
||||
available_tools: vec![],
|
||||
deferred_loading: false,
|
||||
always_expose_tools: Vec::new(),
|
||||
allowed_caller: None,
|
||||
};
|
||||
|
||||
agent
|
||||
.add_extension(extension)
|
||||
.await
|
||||
.map_err(|e| format!("启用 code_execution 扩展失败: {e}"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) async fn stream_reply_once<F>(
|
||||
agent: &Agent,
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
user_message: Message,
|
||||
working_directory: Option<&Path>,
|
||||
session_config: aster::agents::SessionConfig,
|
||||
cancel_token: CancellationToken,
|
||||
request_tool_policy: &RequestToolPolicy,
|
||||
mut on_event: F,
|
||||
) -> Result<(), ReplyAttemptError>
|
||||
where
|
||||
F: FnMut(&TauriAgentEvent),
|
||||
{
|
||||
stream_message_reply_with_policy(
|
||||
agent,
|
||||
user_message,
|
||||
working_directory,
|
||||
session_config,
|
||||
Some(cancel_token),
|
||||
request_tool_policy,
|
||||
|event| {
|
||||
on_event(event);
|
||||
if let Err(error) = app.emit(event_name, event) {
|
||||
tracing::error!("[AsterAgent] 发送事件失败: {}", error);
|
||||
}
|
||||
let app = app.clone();
|
||||
let event_name = event_name.to_string();
|
||||
let event = event.clone();
|
||||
tokio::spawn(async move {
|
||||
maybe_emit_subagent_status_for_runtime_event(&app, &event_name, &event).await;
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_user_message(
|
||||
message_text: &str,
|
||||
images: Option<&[ImageInput]>,
|
||||
) -> Message {
|
||||
let mut message = Message::user();
|
||||
|
||||
if !message_text.is_empty() {
|
||||
message = message.with_text(message_text);
|
||||
}
|
||||
|
||||
if let Some(images) = images {
|
||||
for image in images {
|
||||
if image.data.trim().is_empty() || image.media_type.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
message = message.with_image(image.data.clone(), image.media_type.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if message.content.is_empty() {
|
||||
return Message::user().with_text(message_text);
|
||||
}
|
||||
|
||||
message
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use super::*;
|
||||
|
||||
#[path = "run_metadata/provider_continuation.rs"]
|
||||
mod provider_continuation;
|
||||
#[path = "run_metadata/request_metadata.rs"]
|
||||
mod request_metadata;
|
||||
#[path = "run_metadata/social_artifacts.rs"]
|
||||
mod social_artifacts;
|
||||
|
||||
use provider_continuation::{
|
||||
extract_provider_continuation_from_message, extract_provider_continuation_from_metadata,
|
||||
};
|
||||
use request_metadata::with_string_field;
|
||||
|
||||
pub(super) use provider_continuation::load_previous_provider_continuation_state;
|
||||
#[cfg(test)]
|
||||
pub(super) use provider_continuation::provider_routing_matches_current;
|
||||
pub(super) use request_metadata::{
|
||||
build_chat_run_metadata_base, extract_harness_array, extract_harness_bool,
|
||||
extract_harness_nested_object, extract_harness_string,
|
||||
};
|
||||
pub(super) use social_artifacts::{build_chat_run_finish_metadata, ChatRunObservation};
|
||||
#[cfg(test)]
|
||||
pub(super) use social_artifacts::{
|
||||
extract_artifact_path_from_tool_start, resolve_social_run_artifact_descriptor,
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
|
||||
fn extract_provider_continuation_value(
|
||||
metadata: &HashMap<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
for key in keys {
|
||||
let value = metadata.get(*key).and_then(serde_json::Value::as_str);
|
||||
let Some(value) = normalize_optional_text(value.map(str::to_string)) else {
|
||||
continue;
|
||||
};
|
||||
return Some(value);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) fn extract_provider_continuation_from_metadata(
|
||||
metadata: &HashMap<String, serde_json::Value>,
|
||||
capability: ProviderContinuationCapability,
|
||||
) -> Option<ProviderContinuationState> {
|
||||
match capability {
|
||||
ProviderContinuationCapability::HistoryReplayOnly => None,
|
||||
ProviderContinuationCapability::PreviousResponseId => extract_provider_continuation_value(
|
||||
metadata,
|
||||
&["previous_response_id", "previousResponseId"],
|
||||
)
|
||||
.map(ProviderContinuationState::previous_response_id),
|
||||
ProviderContinuationCapability::ProviderSessionToken => {
|
||||
extract_provider_continuation_value(
|
||||
metadata,
|
||||
&[
|
||||
"provider_session_token",
|
||||
"providerSessionToken",
|
||||
"session_token",
|
||||
"sessionToken",
|
||||
"conversation_id",
|
||||
"conversationId",
|
||||
],
|
||||
)
|
||||
.map(ProviderContinuationState::provider_session_token)
|
||||
}
|
||||
ProviderContinuationCapability::StickyRoutingHint => {
|
||||
extract_provider_continuation_value(metadata, &["routing_hint", "routingHint"])
|
||||
.map(ProviderContinuationState::sticky_routing_hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_provider_continuation_from_message(
|
||||
message: &TauriMessage,
|
||||
capability: ProviderContinuationCapability,
|
||||
) -> Option<ProviderContinuationState> {
|
||||
for content in &message.content {
|
||||
if let TauriMessageContent::ToolResponse {
|
||||
metadata: Some(metadata),
|
||||
..
|
||||
} = content
|
||||
{
|
||||
if let Some(provider_continuation) =
|
||||
extract_provider_continuation_from_metadata(metadata, capability)
|
||||
{
|
||||
return Some(provider_continuation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if message.role == "assistant" {
|
||||
if capability == ProviderContinuationCapability::PreviousResponseId {
|
||||
return message
|
||||
.id
|
||||
.clone()
|
||||
.map(ProviderContinuationState::previous_response_id);
|
||||
}
|
||||
|
||||
if capability == ProviderContinuationCapability::ProviderSessionToken {
|
||||
return message
|
||||
.id
|
||||
.clone()
|
||||
.map(ProviderContinuationState::provider_session_token);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_provider_routing_from_run_metadata(
|
||||
metadata: &serde_json::Value,
|
||||
) -> Option<TurnProviderRoutingSnapshot> {
|
||||
metadata
|
||||
.get("turn_input")
|
||||
.and_then(|value| value.get("provider_routing"))
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value(value).ok())
|
||||
}
|
||||
|
||||
fn extract_provider_continuation_from_run_metadata(
|
||||
metadata: &serde_json::Value,
|
||||
) -> Option<ProviderContinuationState> {
|
||||
metadata
|
||||
.get("provider_continuation")
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
metadata
|
||||
.get("turn_input")
|
||||
.and_then(|value| value.get("provider_continuation"))
|
||||
.cloned()
|
||||
})
|
||||
.and_then(|value| serde_json::from_value::<ProviderContinuationState>(value).ok())
|
||||
.filter(|state| !matches!(state, ProviderContinuationState::HistoryReplayOnly))
|
||||
}
|
||||
|
||||
fn normalize_provider_identifier(value: Option<&str>) -> Option<String> {
|
||||
normalize_optional_text(value.map(str::to_string)).map(|value| value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn provider_routing_matches_current(
|
||||
previous: &TurnProviderRoutingSnapshot,
|
||||
current: &TurnProviderRoutingSnapshot,
|
||||
) -> bool {
|
||||
let same_provider = normalize_provider_identifier(Some(previous.provider_name.as_str()))
|
||||
== normalize_provider_identifier(Some(current.provider_name.as_str()));
|
||||
let same_model = normalize_provider_identifier(Some(previous.model_name.as_str()))
|
||||
== normalize_provider_identifier(Some(current.model_name.as_str()));
|
||||
let same_selector = match (
|
||||
normalize_provider_identifier(previous.provider_selector.as_deref()),
|
||||
normalize_provider_identifier(current.provider_selector.as_deref()),
|
||||
) {
|
||||
(Some(left), Some(right)) => left == right,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
same_provider && same_model && same_selector
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn load_previous_provider_continuation_state(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
current_routing: Option<&TurnProviderRoutingSnapshot>,
|
||||
capability: ProviderContinuationCapability,
|
||||
) -> ProviderContinuationState {
|
||||
if !capability.supports_remote_continuation() {
|
||||
return ProviderContinuationState::history_replay_only();
|
||||
}
|
||||
|
||||
let Some(current_routing) = current_routing else {
|
||||
return ProviderContinuationState::history_replay_only();
|
||||
};
|
||||
|
||||
let conn = match crate::database::lock_db(db) {
|
||||
Ok(conn) => conn,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 读取 provider continuation 时数据库锁定失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
return ProviderContinuationState::history_replay_only();
|
||||
}
|
||||
};
|
||||
|
||||
let runs = match lime_core::database::dao::agent_run::AgentRunDao::list_terminal_runs_by_session(
|
||||
&conn, session_id, 12, 0,
|
||||
) {
|
||||
Ok(runs) => runs,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 查询历史 terminal runs 失败,忽略 provider continuation 恢复: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
return ProviderContinuationState::history_replay_only();
|
||||
}
|
||||
};
|
||||
|
||||
for run in runs {
|
||||
let Some(metadata_text) = run.metadata.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(metadata_value) = serde_json::from_str::<serde_json::Value>(metadata_text) else {
|
||||
continue;
|
||||
};
|
||||
let Some(previous_routing) = extract_provider_routing_from_run_metadata(&metadata_value)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !provider_routing_matches_current(&previous_routing, current_routing) {
|
||||
continue;
|
||||
}
|
||||
let Some(provider_continuation) =
|
||||
extract_provider_continuation_from_run_metadata(&metadata_value)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if provider_continuation.matches_capability(capability) {
|
||||
return provider_continuation;
|
||||
}
|
||||
}
|
||||
|
||||
ProviderContinuationState::history_replay_only()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use super::*;
|
||||
|
||||
fn extract_harness_object(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
let metadata = request_metadata?;
|
||||
let object = metadata.as_object()?;
|
||||
if let Some(harness) = object.get("harness").and_then(serde_json::Value::as_object) {
|
||||
return Some(harness);
|
||||
}
|
||||
Some(object)
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn extract_harness_string(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<String> {
|
||||
let harness = extract_harness_object(request_metadata)?;
|
||||
keys.iter()
|
||||
.filter_map(|key| harness.get(*key))
|
||||
.find_map(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn extract_harness_bool(
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<bool> {
|
||||
let harness = extract_harness_object(request_metadata)?;
|
||||
keys.iter()
|
||||
.filter_map(|key| harness.get(*key))
|
||||
.find_map(serde_json::Value::as_bool)
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn extract_harness_array<'a>(
|
||||
request_metadata: Option<&'a serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a Vec<serde_json::Value>> {
|
||||
let harness = extract_harness_object(request_metadata)?;
|
||||
keys.iter()
|
||||
.filter_map(|key| harness.get(*key))
|
||||
.find_map(serde_json::Value::as_array)
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn extract_harness_nested_object<'a>(
|
||||
request_metadata: Option<&'a serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
|
||||
let harness = extract_harness_object(request_metadata)?;
|
||||
keys.iter()
|
||||
.filter_map(|key| harness.get(*key))
|
||||
.find_map(serde_json::Value::as_object)
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
|
||||
target: &mut serde_json::Map<String, serde_json::Value>,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) {
|
||||
if let Some(metadata) = request_metadata {
|
||||
target.insert("request_metadata".to_string(), metadata.clone());
|
||||
}
|
||||
|
||||
let Some(harness) = extract_harness_object(request_metadata) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (source_key, target_key) in [
|
||||
("theme", "harness_theme"),
|
||||
("harness_theme", "harness_theme"),
|
||||
("creation_mode", "creation_mode"),
|
||||
("creationMode", "creation_mode"),
|
||||
("chat_mode", "chat_mode"),
|
||||
("chatMode", "chat_mode"),
|
||||
("session_mode", "session_mode"),
|
||||
("sessionMode", "session_mode"),
|
||||
("gate_key", "gate_key"),
|
||||
("gateKey", "gate_key"),
|
||||
("run_title", "run_title"),
|
||||
("runTitle", "run_title"),
|
||||
("content_id", "content_id"),
|
||||
("contentId", "content_id"),
|
||||
("preferred_team_preset_id", "preferred_team_preset_id"),
|
||||
("preferredTeamPresetId", "preferred_team_preset_id"),
|
||||
("selected_team_id", "selected_team_id"),
|
||||
("selectedTeamId", "selected_team_id"),
|
||||
("selected_team_source", "selected_team_source"),
|
||||
("selectedTeamSource", "selected_team_source"),
|
||||
("selected_team_label", "selected_team_label"),
|
||||
("selectedTeamLabel", "selected_team_label"),
|
||||
("selected_team_summary", "selected_team_summary"),
|
||||
("selectedTeamSummary", "selected_team_summary"),
|
||||
("selected_team_roles", "selected_team_roles"),
|
||||
("selectedTeamRoles", "selected_team_roles"),
|
||||
("browser_requirement", "browser_requirement"),
|
||||
("browserRequirement", "browser_requirement"),
|
||||
("browser_requirement_reason", "browser_requirement_reason"),
|
||||
("browserRequirementReason", "browser_requirement_reason"),
|
||||
("browser_launch_url", "browser_launch_url"),
|
||||
("browserLaunchUrl", "browser_launch_url"),
|
||||
] {
|
||||
if target.contains_key(target_key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = harness.get(source_key) {
|
||||
target.insert(target_key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
|
||||
request: &AsterChatRequest,
|
||||
workspace_id: &str,
|
||||
effective_strategy: AsterExecutionStrategy,
|
||||
request_tool_policy: &RequestToolPolicy,
|
||||
auto_continue_enabled: bool,
|
||||
auto_continue_metadata: Option<&AutoContinuePayload>,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut metadata = serde_json::Map::new();
|
||||
metadata.insert("workspace_id".to_string(), serde_json::json!(workspace_id));
|
||||
metadata.insert(
|
||||
"project_id".to_string(),
|
||||
serde_json::json!(request.project_id.clone()),
|
||||
);
|
||||
metadata.insert(
|
||||
"event_name".to_string(),
|
||||
serde_json::json!(request.event_name.clone()),
|
||||
);
|
||||
metadata.insert(
|
||||
"execution_strategy".to_string(),
|
||||
serde_json::json!(format!("{:?}", effective_strategy).to_lowercase()),
|
||||
);
|
||||
metadata.insert(
|
||||
"message_length".to_string(),
|
||||
serde_json::json!(request.message.chars().count()),
|
||||
);
|
||||
metadata.insert(
|
||||
"web_search_enabled".to_string(),
|
||||
serde_json::json!(request_tool_policy.effective_web_search),
|
||||
);
|
||||
metadata.insert(
|
||||
"web_search_mode".to_string(),
|
||||
serde_json::json!(request_tool_policy.search_mode.as_str()),
|
||||
);
|
||||
metadata.insert(
|
||||
"auto_continue_enabled".to_string(),
|
||||
serde_json::json!(auto_continue_enabled),
|
||||
);
|
||||
metadata.insert(
|
||||
"auto_continue".to_string(),
|
||||
serde_json::json!(auto_continue_metadata),
|
||||
);
|
||||
extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref());
|
||||
metadata
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn with_string_field(
|
||||
target: &mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
value: Option<&str>,
|
||||
) {
|
||||
if target.contains_key(key) {
|
||||
return;
|
||||
}
|
||||
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
target.insert(key.to_string(), serde_json::json!(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(in crate::commands::aster_agent_cmd) struct SocialRunArtifactDescriptor {
|
||||
pub(in crate::commands::aster_agent_cmd) artifact_id: String,
|
||||
pub(in crate::commands::aster_agent_cmd) artifact_type: String,
|
||||
pub(in crate::commands::aster_agent_cmd) stage: String,
|
||||
pub(in crate::commands::aster_agent_cmd) stage_label: String,
|
||||
pub(in crate::commands::aster_agent_cmd) version_label: String,
|
||||
pub(in crate::commands::aster_agent_cmd) source_file_name: String,
|
||||
pub(in crate::commands::aster_agent_cmd) branch_key: String,
|
||||
pub(in crate::commands::aster_agent_cmd) platform: Option<String>,
|
||||
pub(in crate::commands::aster_agent_cmd) is_auxiliary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(in crate::commands::aster_agent_cmd) struct ChatRunObservation {
|
||||
pub(in crate::commands::aster_agent_cmd) artifact_paths: Vec<String>,
|
||||
pub(in crate::commands::aster_agent_cmd) primary_social_artifact:
|
||||
Option<SocialRunArtifactDescriptor>,
|
||||
pub(in crate::commands::aster_agent_cmd) provider_continuation:
|
||||
Option<ProviderContinuationState>,
|
||||
}
|
||||
|
||||
impl ChatRunObservation {
|
||||
pub(in crate::commands::aster_agent_cmd) fn record_event(
|
||||
&mut self,
|
||||
event: &TauriAgentEvent,
|
||||
workspace_root: &str,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
provider_continuation_capability: ProviderContinuationCapability,
|
||||
) {
|
||||
match event {
|
||||
TauriAgentEvent::ToolStart {
|
||||
tool_name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
if let Some(path) = extract_artifact_path_from_tool_start(
|
||||
tool_name,
|
||||
arguments.as_deref(),
|
||||
workspace_root,
|
||||
) {
|
||||
self.record_artifact_path(path, request_metadata);
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ToolEnd { result, .. } => {
|
||||
if let Some(metadata) = &result.metadata {
|
||||
if let Some(provider_continuation) = extract_provider_continuation_from_metadata(
|
||||
metadata,
|
||||
provider_continuation_capability,
|
||||
) {
|
||||
self.record_provider_continuation(provider_continuation);
|
||||
}
|
||||
for path in
|
||||
extract_artifact_paths_from_tool_result_metadata(metadata, workspace_root)
|
||||
{
|
||||
self.record_artifact_path(path, request_metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::Message { message } => {
|
||||
if let Some(provider_continuation) = extract_provider_continuation_from_message(
|
||||
message,
|
||||
provider_continuation_capability,
|
||||
) {
|
||||
self.record_provider_continuation(provider_continuation);
|
||||
}
|
||||
}
|
||||
TauriAgentEvent::ArtifactSnapshot { artifact } => {
|
||||
if let Some(path) =
|
||||
normalize_metadata_path(artifact.file_path.as_str(), workspace_root)
|
||||
{
|
||||
self.record_artifact_path(path, request_metadata);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_provider_continuation(&mut self, provider_continuation: ProviderContinuationState) {
|
||||
if matches!(
|
||||
provider_continuation,
|
||||
ProviderContinuationState::HistoryReplayOnly
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.provider_continuation = Some(provider_continuation);
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn record_artifact_path(
|
||||
&mut self,
|
||||
path: String,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
) {
|
||||
if path.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.artifact_paths.iter().any(|item| item == &path) {
|
||||
self.artifact_paths.push(path.clone());
|
||||
}
|
||||
|
||||
if !should_track_social_artifact(request_metadata, path.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let gate_key = extract_harness_string(request_metadata, &["gate_key", "gateKey"]);
|
||||
let run_title =
|
||||
extract_harness_string(request_metadata, &["run_title", "runTitle", "title"]);
|
||||
let candidate = resolve_social_run_artifact_descriptor(
|
||||
path.as_str(),
|
||||
gate_key.as_deref(),
|
||||
run_title.as_deref(),
|
||||
);
|
||||
let should_replace = match self.primary_social_artifact.as_ref() {
|
||||
None => true,
|
||||
Some(existing) if existing.is_auxiliary && !candidate.is_auxiliary => true,
|
||||
_ => false,
|
||||
};
|
||||
if should_replace {
|
||||
self.primary_social_artifact = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_metadata_path(raw: &str, workspace_root: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let normalized = trimmed.replace('\\', "/");
|
||||
let normalized_root = workspace_root.trim().replace('\\', "/");
|
||||
|
||||
if !normalized_root.is_empty() && normalized.starts_with(normalized_root.as_str()) {
|
||||
let suffix = normalized
|
||||
.strip_prefix(normalized_root.as_str())
|
||||
.unwrap_or(normalized.as_str())
|
||||
.trim_start_matches('/')
|
||||
.to_string();
|
||||
if !suffix.is_empty() {
|
||||
return Some(suffix);
|
||||
}
|
||||
}
|
||||
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn parse_tool_arguments(arguments: Option<&str>) -> Option<serde_json::Value> {
|
||||
let raw = arguments?.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(raw).ok()
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn extract_artifact_path_from_tool_start(
|
||||
tool_name: &str,
|
||||
arguments: Option<&str>,
|
||||
workspace_root: &str,
|
||||
) -> Option<String> {
|
||||
let normalized_tool_name = tool_name.trim().to_lowercase();
|
||||
if normalized_tool_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let args = parse_tool_arguments(arguments)?;
|
||||
let object = args.as_object()?;
|
||||
|
||||
for key in ["path", "file_path", "filePath", "output_path", "outputPath"] {
|
||||
let Some(raw_path) = object.get(key).and_then(serde_json::Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if normalized_tool_name.contains("write")
|
||||
|| normalized_tool_name.contains("create")
|
||||
|| normalized_tool_name.contains("output")
|
||||
{
|
||||
return normalize_metadata_path(raw_path, workspace_root);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn push_metadata_path(target: &mut Vec<String>, value: &serde_json::Value, workspace_root: &str) {
|
||||
match value {
|
||||
serde_json::Value::String(path) => {
|
||||
if let Some(normalized) = normalize_metadata_path(path, workspace_root) {
|
||||
if !target.iter().any(|item| item == &normalized) {
|
||||
target.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
push_metadata_path(target, item, workspace_root);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_artifact_paths_from_tool_result_metadata(
|
||||
metadata: &HashMap<String, serde_json::Value>,
|
||||
workspace_root: &str,
|
||||
) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
for key in [
|
||||
"artifact_paths",
|
||||
"artifact_path",
|
||||
"path",
|
||||
"absolute_path",
|
||||
"output_file",
|
||||
"file_path",
|
||||
"output_path",
|
||||
"article_path",
|
||||
"cover_meta_path",
|
||||
"publish_path",
|
||||
] {
|
||||
if let Some(value) = metadata.get(key) {
|
||||
push_metadata_path(&mut paths, value, workspace_root);
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn should_track_social_artifact(request_metadata: Option<&serde_json::Value>, path: &str) -> bool {
|
||||
if extract_harness_string(request_metadata, &["theme", "harness_theme"])
|
||||
.map(|theme| theme == "social-media")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
path.to_lowercase().contains("social")
|
||||
}
|
||||
|
||||
fn normalize_artifact_file_name(file_name: &str) -> String {
|
||||
file_name.replace('\\', "/").trim().to_string()
|
||||
}
|
||||
|
||||
fn artifact_base_name(file_name: &str) -> String {
|
||||
normalize_artifact_file_name(file_name)
|
||||
.split('/')
|
||||
.last()
|
||||
.unwrap_or(file_name)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn strip_social_known_suffix(file_name: &str) -> String {
|
||||
let base_name = artifact_base_name(file_name);
|
||||
if let Some(value) = base_name.strip_suffix(".publish-pack.json") {
|
||||
return value.to_string();
|
||||
}
|
||||
if let Some(value) = base_name.strip_suffix(".cover.json") {
|
||||
return value.to_string();
|
||||
}
|
||||
base_name
|
||||
.rsplit_once('.')
|
||||
.map(|(prefix, _)| prefix.to_string())
|
||||
.unwrap_or(base_name)
|
||||
}
|
||||
|
||||
fn to_social_branch_key(file_name: &str) -> String {
|
||||
let mut branch_key = String::new();
|
||||
let mut last_is_dash = false;
|
||||
for ch in strip_social_known_suffix(file_name).chars() {
|
||||
let keep = ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch);
|
||||
if keep {
|
||||
branch_key.push(ch.to_ascii_lowercase());
|
||||
last_is_dash = false;
|
||||
} else if !last_is_dash {
|
||||
branch_key.push('-');
|
||||
last_is_dash = true;
|
||||
}
|
||||
}
|
||||
let branch_key = branch_key.trim_matches('-').to_string();
|
||||
if branch_key.is_empty() {
|
||||
"artifact".to_string()
|
||||
} else {
|
||||
branch_key
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_social_platform_from_text(text: &str) -> Option<String> {
|
||||
let normalized = text.to_lowercase();
|
||||
if normalized.contains("xiaohongshu") || normalized.contains("xhs") || text.contains("小红书")
|
||||
{
|
||||
return Some("xiaohongshu".to_string());
|
||||
}
|
||||
if normalized.contains("wechat")
|
||||
|| normalized.contains("weixin")
|
||||
|| normalized.contains("gzh")
|
||||
|| text.contains("公众号")
|
||||
|| text.contains("微信")
|
||||
{
|
||||
return Some("wechat".to_string());
|
||||
}
|
||||
if normalized.contains("zhihu") || text.contains("知乎") {
|
||||
return Some("zhihu".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_social_artifact_type(
|
||||
normalized_file_name: &str,
|
||||
platform: Option<&str>,
|
||||
gate_key: Option<&str>,
|
||||
) -> String {
|
||||
let base_name = artifact_base_name(normalized_file_name).to_lowercase();
|
||||
if base_name.ends_with(".publish-pack.json") {
|
||||
return "publish_package".to_string();
|
||||
}
|
||||
if base_name.ends_with(".cover.json") {
|
||||
return "cover_meta".to_string();
|
||||
}
|
||||
if !base_name.ends_with(".md") {
|
||||
return "asset".to_string();
|
||||
}
|
||||
if base_name == "brief.md" || base_name.contains("brief") {
|
||||
return "brief".to_string();
|
||||
}
|
||||
if base_name == "draft.md" || base_name.contains("draft") {
|
||||
return "draft".to_string();
|
||||
}
|
||||
if base_name == "article.md" || base_name.contains("article") || base_name.contains("final") {
|
||||
return "polished".to_string();
|
||||
}
|
||||
if base_name == "adapted.md" || base_name.contains("adapt") {
|
||||
return "platform_variant".to_string();
|
||||
}
|
||||
if platform.is_some() {
|
||||
return "platform_variant".to_string();
|
||||
}
|
||||
match gate_key.unwrap_or_default() {
|
||||
"topic_select" => "brief".to_string(),
|
||||
"publish_confirm" => {
|
||||
if platform.is_some() {
|
||||
"platform_variant".to_string()
|
||||
} else {
|
||||
"polished".to_string()
|
||||
}
|
||||
}
|
||||
_ => "draft".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_social_stage_for_artifact(artifact_type: &str, gate_key: Option<&str>) -> String {
|
||||
match artifact_type {
|
||||
"brief" => "briefing".to_string(),
|
||||
"draft" => "drafting".to_string(),
|
||||
"polished" => "polishing".to_string(),
|
||||
"platform_variant" => "adapting".to_string(),
|
||||
"cover_meta" | "publish_package" => "publish_prep".to_string(),
|
||||
_ => match gate_key.unwrap_or("idle") {
|
||||
"topic_select" => "briefing".to_string(),
|
||||
"publish_confirm" => "publish_prep".to_string(),
|
||||
_ => "drafting".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_social_stage_label(stage: &str) -> String {
|
||||
match stage {
|
||||
"briefing" => "需求澄清".to_string(),
|
||||
"drafting" => "初稿创作".to_string(),
|
||||
"polishing" => "润色优化".to_string(),
|
||||
"adapting" => "平台适配".to_string(),
|
||||
"publish_prep" => "发布准备".to_string(),
|
||||
_ => "社媒创作".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_social_version_label(artifact_type: &str, platform: Option<&str>) -> String {
|
||||
match artifact_type {
|
||||
"brief" => "需求简报".to_string(),
|
||||
"draft" => "社媒初稿".to_string(),
|
||||
"polished" => "润色成稿".to_string(),
|
||||
"platform_variant" => match platform {
|
||||
Some("xiaohongshu") => "平台适配 · 小红书".to_string(),
|
||||
Some("wechat") => "平台适配 · 公众号".to_string(),
|
||||
Some("zhihu") => "平台适配 · 知乎".to_string(),
|
||||
_ => "平台适配".to_string(),
|
||||
},
|
||||
"cover_meta" => "封面配置".to_string(),
|
||||
"publish_package" => "发布包".to_string(),
|
||||
_ => "社媒产物".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn resolve_social_run_artifact_descriptor(
|
||||
file_name: &str,
|
||||
gate_key: Option<&str>,
|
||||
run_title: Option<&str>,
|
||||
) -> SocialRunArtifactDescriptor {
|
||||
let normalized_file_name = normalize_artifact_file_name(file_name);
|
||||
let platform = infer_social_platform_from_text(
|
||||
format!("{} {}", normalized_file_name, run_title.unwrap_or_default()).as_str(),
|
||||
);
|
||||
let artifact_type =
|
||||
resolve_social_artifact_type(normalized_file_name.as_str(), platform.as_deref(), gate_key);
|
||||
let stage = resolve_social_stage_for_artifact(artifact_type.as_str(), gate_key);
|
||||
let branch_key = to_social_branch_key(normalized_file_name.as_str());
|
||||
let artifact_suffix = match platform.as_deref() {
|
||||
Some(platform) => format!("{branch_key}:{platform}"),
|
||||
None => branch_key.clone(),
|
||||
};
|
||||
|
||||
SocialRunArtifactDescriptor {
|
||||
artifact_id: format!("social-media:{}:{}", artifact_type, artifact_suffix),
|
||||
artifact_type: artifact_type.clone(),
|
||||
stage: stage.clone(),
|
||||
stage_label: resolve_social_stage_label(stage.as_str()),
|
||||
version_label: resolve_social_version_label(artifact_type.as_str(), platform.as_deref()),
|
||||
source_file_name: normalized_file_name,
|
||||
branch_key,
|
||||
platform,
|
||||
is_auxiliary: matches!(
|
||||
artifact_type.as_str(),
|
||||
"cover_meta" | "publish_package" | "asset"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_gate_key_from_social_stage(stage: &str) -> Option<&'static str> {
|
||||
match stage {
|
||||
"briefing" => Some("topic_select"),
|
||||
"drafting" | "polishing" => Some("write_mode"),
|
||||
"adapting" | "publish_prep" => Some("publish_confirm"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::commands::aster_agent_cmd) fn build_chat_run_finish_metadata(
|
||||
base_metadata: &serde_json::Map<String, serde_json::Value>,
|
||||
observation: &ChatRunObservation,
|
||||
) -> serde_json::Value {
|
||||
let mut metadata = base_metadata.clone();
|
||||
|
||||
if !observation.artifact_paths.is_empty() {
|
||||
metadata.insert(
|
||||
"artifact_paths".to_string(),
|
||||
serde_json::json!(observation.artifact_paths.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(artifact) = observation.primary_social_artifact.as_ref() {
|
||||
with_string_field(&mut metadata, "harness_theme", Some("social-media"));
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"artifact_id",
|
||||
Some(artifact.artifact_id.as_str()),
|
||||
);
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"artifact_type",
|
||||
Some(artifact.artifact_type.as_str()),
|
||||
);
|
||||
with_string_field(&mut metadata, "stage", Some(artifact.stage.as_str()));
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"stage_label",
|
||||
Some(artifact.stage_label.as_str()),
|
||||
);
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"version_label",
|
||||
Some(artifact.version_label.as_str()),
|
||||
);
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"branch_key",
|
||||
Some(artifact.branch_key.as_str()),
|
||||
);
|
||||
with_string_field(&mut metadata, "platform", artifact.platform.as_deref());
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"source_file_name",
|
||||
Some(artifact.source_file_name.as_str()),
|
||||
);
|
||||
let version_id = format!("artifact:{}", artifact.source_file_name);
|
||||
with_string_field(&mut metadata, "version_id", Some(version_id.as_str()));
|
||||
|
||||
if !metadata.contains_key("gate_key") {
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"gate_key",
|
||||
infer_gate_key_from_social_stage(artifact.stage.as_str()),
|
||||
);
|
||||
}
|
||||
if !metadata.contains_key("run_title") {
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"run_title",
|
||||
Some(artifact.version_label.as_str()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(provider_continuation) = observation.provider_continuation.as_ref() {
|
||||
if let Ok(provider_continuation_value) = serde_json::to_value(provider_continuation) {
|
||||
metadata.insert(
|
||||
"provider_continuation".to_string(),
|
||||
provider_continuation_value,
|
||||
);
|
||||
}
|
||||
metadata.insert(
|
||||
"provider_continuation_observed".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
with_string_field(
|
||||
&mut metadata,
|
||||
"provider_continuation_kind",
|
||||
Some(provider_continuation.kind()),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::Value::Object(metadata)
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
use super::*;
|
||||
|
||||
async fn execute_aster_chat_request(
|
||||
app: &AppHandle,
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
api_key_provider_service: &ApiKeyProviderServiceState,
|
||||
logs: &LogState,
|
||||
config_manager: &GlobalConfigManagerState,
|
||||
mcp_manager: &McpManagerState,
|
||||
automation_state: &AutomationServiceState,
|
||||
request: AsterChatRequest,
|
||||
) -> Result<(), String> {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 发送流式消息: session={}, event={}",
|
||||
request.session_id,
|
||||
request.event_name
|
||||
);
|
||||
|
||||
// 确保 Agent 已初始化(使用带数据库的版本,注入 SessionStore)
|
||||
let is_init = state.is_initialized().await;
|
||||
tracing::warn!("[AsterAgent] Agent 初始化状态: {}", is_init);
|
||||
if !is_init {
|
||||
tracing::warn!("[AsterAgent] Agent 未初始化,开始初始化...");
|
||||
state.init_agent_with_db(db).await?;
|
||||
tracing::warn!("[AsterAgent] Agent 初始化完成");
|
||||
} else {
|
||||
tracing::warn!("[AsterAgent] Agent 已初始化,检查 session_store...");
|
||||
// 检查 session_store 是否存在
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
if let Some(agent) = guard.as_ref() {
|
||||
let has_store = agent.session_store().is_some();
|
||||
tracing::warn!("[AsterAgent] session_store 存在: {}", has_store);
|
||||
}
|
||||
}
|
||||
ensure_tool_search_tool_registered(state).await?;
|
||||
|
||||
// 直接使用前端传递的 session_id
|
||||
// LimeSessionStore 会在 add_message 时自动创建不存在的 session
|
||||
// 同时 get_session 也会自动创建不存在的 session
|
||||
let session_id = &request.session_id;
|
||||
|
||||
let workspace_id = request.workspace_id.trim().to_string();
|
||||
if workspace_id.is_empty() {
|
||||
let message = "workspace_id 必填,请先选择项目工作区".to_string();
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[AsterAgent] {}", message));
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
let manager = WorkspaceManager::new(db.clone());
|
||||
let workspace = match manager.get(&workspace_id) {
|
||||
Ok(Some(workspace)) => workspace,
|
||||
Ok(None) => {
|
||||
let message = format!("Workspace 不存在: {workspace_id}");
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[AsterAgent] {}", message));
|
||||
return Err(message);
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("读取 workspace 失败: {error}");
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[AsterAgent] {}", message));
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let ensured = match ensure_workspace_ready_with_auto_relocate(&manager, &workspace) {
|
||||
Ok(result) => result,
|
||||
Err(message) => {
|
||||
logs.write()
|
||||
.await
|
||||
.add("error", &format!("[AsterAgent] {}", message));
|
||||
return Err(message);
|
||||
}
|
||||
};
|
||||
let workspace_root = ensured.root_path.to_string_lossy().to_string();
|
||||
let runtime_config = config_manager.config();
|
||||
apply_web_search_runtime_env(&runtime_config);
|
||||
let auto_continue_config = request
|
||||
.auto_continue
|
||||
.clone()
|
||||
.map(AutoContinuePayload::normalized);
|
||||
let auto_continue_enabled = auto_continue_config
|
||||
.as_ref()
|
||||
.map(|config| config.enabled)
|
||||
.unwrap_or(false);
|
||||
if let Some(config) = auto_continue_config
|
||||
.as_ref()
|
||||
.filter(|config| config.enabled)
|
||||
{
|
||||
tracing::info!(
|
||||
"[AsterAgent] 自动续写策略已启用: source={:?}, fast_mode={}, continuation_length={}, sensitivity={}",
|
||||
config.source,
|
||||
config.fast_mode_enabled,
|
||||
config.continuation_length,
|
||||
config.sensitivity
|
||||
);
|
||||
}
|
||||
|
||||
if ensured.repaired {
|
||||
let warning_message = ensured.warning.unwrap_or_else(|| {
|
||||
format!(
|
||||
"检测到工作区目录缺失,已自动创建并继续执行: {}",
|
||||
workspace_root
|
||||
)
|
||||
});
|
||||
logs.write()
|
||||
.await
|
||||
.add("warn", &format!("[AsterAgent] {}", warning_message));
|
||||
let warning_event = TauriAgentEvent::Warning {
|
||||
code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()),
|
||||
message: warning_message,
|
||||
};
|
||||
if let Err(error) = app.emit(&request.event_name, &warning_event) {
|
||||
tracing::error!("[AsterAgent] 发送工作区自动恢复提醒失败: {}", error);
|
||||
}
|
||||
}
|
||||
|
||||
let mut session_state_snapshot = SessionStateSnapshot::from_persisted_metadata(
|
||||
session_id,
|
||||
AsterAgentWrapper::get_persisted_session_metadata_sync(db, session_id)?,
|
||||
);
|
||||
|
||||
if session_state_snapshot.needs_working_dir_update(&workspace_root) {
|
||||
tracing::info!(
|
||||
"[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}",
|
||||
session_state_snapshot.working_dir().unwrap_or_default(),
|
||||
workspace_root
|
||||
);
|
||||
AsterAgentWrapper::update_session_working_dir_sync(db, session_id, &workspace_root)?;
|
||||
session_state_snapshot =
|
||||
session_state_snapshot.with_working_dir(Some(workspace_root.clone()));
|
||||
}
|
||||
|
||||
// 启动并注入 MCP extensions 到 Aster Agent
|
||||
let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await;
|
||||
if start_fail > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整",
|
||||
start_fail
|
||||
);
|
||||
}
|
||||
|
||||
let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await;
|
||||
if mcp_fail > 0 {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具",
|
||||
mcp_fail
|
||||
);
|
||||
}
|
||||
|
||||
let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref());
|
||||
let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode);
|
||||
let (request_web_search, request_search_mode) =
|
||||
apply_browser_requirement_to_request_tool_policy(
|
||||
request.metadata.as_ref(),
|
||||
request.web_search,
|
||||
request.search_mode,
|
||||
);
|
||||
|
||||
// 构建请求级工具策略:
|
||||
// - web_search=true 默认只表示“允许搜索”
|
||||
// - 仅显式 search_mode=required 时才强制预搜索
|
||||
let request_tool_policy = resolve_request_tool_policy_with_mode(
|
||||
request_web_search,
|
||||
request_search_mode,
|
||||
mode_default_web_search,
|
||||
);
|
||||
tracing::info!(
|
||||
"[AsterAgent][WebSearchGuard] session={}, chat_mode={:?}, request_web_search={:?}, request_search_mode={:?}, effective_request_web_search={:?}, effective_request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}",
|
||||
session_id,
|
||||
runtime_chat_mode,
|
||||
request.web_search,
|
||||
request.search_mode,
|
||||
request_web_search,
|
||||
request_search_mode,
|
||||
mode_default_web_search,
|
||||
request_tool_policy.effective_web_search,
|
||||
request_tool_policy.search_mode.as_str()
|
||||
);
|
||||
|
||||
let include_context_trace = runtime_config.memory.enabled;
|
||||
let has_persisted_session = session_state_snapshot.has_persisted_session();
|
||||
tracing::info!(
|
||||
"[AsterAgent] session_state_snapshot={}",
|
||||
serde_json::to_string(&session_state_snapshot).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
let mut turn_input_builder = TurnInputEnvelopeBuilder::new(session_id, workspace_id.as_str());
|
||||
turn_input_builder
|
||||
.set_project_id(request.project_id.clone())
|
||||
.set_has_persisted_session(has_persisted_session)
|
||||
.set_request_tool_policy(Some(TurnRequestToolPolicySnapshot::from(
|
||||
&request_tool_policy,
|
||||
)))
|
||||
.set_working_dir(Some(workspace_root.clone()))
|
||||
.set_effective_user_message(request.message.clone())
|
||||
.set_include_context_trace(include_context_trace)
|
||||
.set_turn_context_metadata_from_value(request.metadata.as_ref());
|
||||
|
||||
// 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt
|
||||
// 同时读取会话已持久化的 execution_strategy
|
||||
let persisted_strategy =
|
||||
AsterExecutionStrategy::from_db_value(session_state_snapshot.execution_strategy());
|
||||
let session_prompt = if let Some(prompt) = session_state_snapshot.system_prompt() {
|
||||
tracing::debug!(
|
||||
"[AsterAgent] 找到 session,system_prompt: {:?}",
|
||||
Some(prompt.len())
|
||||
);
|
||||
Some(prompt.to_string())
|
||||
} else {
|
||||
if !session_state_snapshot.has_persisted_session() {
|
||||
tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id);
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
let project_prompt = if let Some(ref project_id) = request.project_id {
|
||||
match AsterAgentState::build_project_system_prompt(db, project_id) {
|
||||
Ok(prompt) => {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 已加载项目上下文: project_id={}, prompt_len={}",
|
||||
project_id,
|
||||
prompt.len()
|
||||
);
|
||||
Some(prompt)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 加载项目上下文失败: {}, 继续使用 session prompt",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (resolved_prompt, system_prompt_source) = if let Some(project_prompt) = project_prompt {
|
||||
(Some(project_prompt), TurnSystemPromptSource::Project)
|
||||
} else if let Some(session_prompt) = session_prompt {
|
||||
(Some(session_prompt), TurnSystemPromptSource::Session)
|
||||
} else if let Some(ref frontend_prompt) = request.system_prompt {
|
||||
if !frontend_prompt.trim().is_empty() {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 使用前端传入的 system_prompt, len={}",
|
||||
frontend_prompt.len()
|
||||
);
|
||||
(
|
||||
Some(frontend_prompt.clone()),
|
||||
TurnSystemPromptSource::Frontend,
|
||||
)
|
||||
} else {
|
||||
(None, TurnSystemPromptSource::None)
|
||||
}
|
||||
} else {
|
||||
(None, TurnSystemPromptSource::None)
|
||||
};
|
||||
turn_input_builder.set_base_system_prompt(system_prompt_source, resolved_prompt.clone());
|
||||
|
||||
let prompt_with_runtime_agents =
|
||||
merge_system_prompt_with_runtime_agents(resolved_prompt, Some(Path::new(&workspace_root)));
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::RuntimeAgents,
|
||||
prompt_with_runtime_agents.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_memory = merge_system_prompt_with_memory_context(
|
||||
prompt_with_runtime_agents,
|
||||
&runtime_config,
|
||||
MemoryPromptContext::with_working_dir(Path::new(&workspace_root)),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::Memory,
|
||||
prompt_with_memory.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_web_search =
|
||||
merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::WebSearch,
|
||||
prompt_with_web_search.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_request_policy =
|
||||
merge_system_prompt_with_request_tool_policy(prompt_with_web_search, &request_tool_policy);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::RequestToolPolicy,
|
||||
prompt_with_request_policy.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_elicitation = merge_system_prompt_with_elicitation_context(
|
||||
prompt_with_request_policy,
|
||||
request.metadata.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::Elicitation,
|
||||
prompt_with_elicitation.clone(),
|
||||
);
|
||||
|
||||
let prompt_with_team_preference = merge_system_prompt_with_team_preference(
|
||||
prompt_with_elicitation,
|
||||
request.metadata.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::TeamPreference,
|
||||
prompt_with_team_preference.clone(),
|
||||
);
|
||||
|
||||
let system_prompt = merge_system_prompt_with_auto_continue(
|
||||
prompt_with_team_preference,
|
||||
auto_continue_config.as_ref(),
|
||||
);
|
||||
turn_input_builder.apply_prompt_stage(
|
||||
TurnPromptAugmentationStageKind::AutoContinue,
|
||||
system_prompt.clone(),
|
||||
);
|
||||
|
||||
let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy);
|
||||
let effective_strategy = requested_strategy.effective_for_message(&request.message);
|
||||
turn_input_builder
|
||||
.set_requested_execution_strategy(Some(requested_strategy.as_db_value().to_string()))
|
||||
.set_effective_execution_strategy(Some(effective_strategy.as_db_value().to_string()));
|
||||
|
||||
if let Some(explicit_strategy) = request.execution_strategy {
|
||||
if has_persisted_session {
|
||||
if let Err(error) = AsterAgentWrapper::update_session_execution_strategy_sync(
|
||||
db,
|
||||
session_id,
|
||||
explicit_strategy.as_db_value(),
|
||||
) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 更新会话执行策略失败: session={}, strategy={}, error={}",
|
||||
session_id,
|
||||
explicit_strategy.as_db_value(),
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[AsterAgent] 执行策略: requested={:?}, effective={:?}",
|
||||
requested_strategy,
|
||||
effective_strategy
|
||||
);
|
||||
|
||||
// 如果提供了 Provider 配置,则配置 Provider
|
||||
if let Some(provider_config) = &request.provider_config {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 收到 provider_config: provider_id={:?}, provider_name={}, model_name={}, has_api_key={}, base_url={:?}",
|
||||
provider_config.provider_id,
|
||||
provider_config.provider_name,
|
||||
provider_config.model_name,
|
||||
provider_config.api_key.is_some(),
|
||||
provider_config.base_url
|
||||
);
|
||||
let config = ProviderConfig {
|
||||
provider_name: provider_config.provider_name.clone(),
|
||||
provider_selector: provider_config
|
||||
.provider_id
|
||||
.clone()
|
||||
.or_else(|| Some(provider_config.provider_name.clone())),
|
||||
model_name: provider_config.model_name.clone(),
|
||||
api_key: provider_config.api_key.clone(),
|
||||
base_url: provider_config.base_url.clone(),
|
||||
credential_uuid: None,
|
||||
force_responses_api: false,
|
||||
credential_path: None,
|
||||
};
|
||||
// 如果前端提供了 api_key,直接使用;否则从凭证池选择凭证
|
||||
if provider_config.api_key.is_some() {
|
||||
state.configure_provider(config, session_id, db).await?;
|
||||
let provider_selector = provider_config
|
||||
.provider_id
|
||||
.as_deref()
|
||||
.unwrap_or(&provider_config.provider_name);
|
||||
persist_session_provider_routing(session_id, provider_selector).await?;
|
||||
} else {
|
||||
// 没有 api_key,使用凭证池(优先 provider_id,其次 provider_name)
|
||||
let provider_selector = provider_config
|
||||
.provider_id
|
||||
.as_deref()
|
||||
.unwrap_or(&provider_config.provider_name);
|
||||
state
|
||||
.configure_provider_from_pool(
|
||||
db,
|
||||
provider_selector,
|
||||
&provider_config.model_name,
|
||||
session_id,
|
||||
)
|
||||
.await?;
|
||||
persist_session_provider_routing(session_id, provider_selector).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Provider 是否已配置
|
||||
if !state.is_provider_configured().await {
|
||||
return Err("Provider 未配置,请先调用 aster_agent_configure_provider".to_string());
|
||||
}
|
||||
let effective_provider_config = state.get_provider_config().await;
|
||||
let provider_routing_snapshot =
|
||||
effective_provider_config
|
||||
.as_ref()
|
||||
.map(|config| TurnProviderRoutingSnapshot {
|
||||
provider_name: config.provider_name.clone(),
|
||||
provider_selector: config.provider_selector.clone(),
|
||||
model_name: config.model_name.clone(),
|
||||
credential_uuid: config.credential_uuid.clone(),
|
||||
configured_from_request: request.provider_config.is_some(),
|
||||
used_inline_api_key: request
|
||||
.provider_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.api_key.as_ref())
|
||||
.is_some(),
|
||||
});
|
||||
turn_input_builder.set_provider_routing(provider_routing_snapshot.clone());
|
||||
let provider_continuation_capability = effective_provider_config
|
||||
.as_ref()
|
||||
.map(|config| config.provider_continuation_capability())
|
||||
.unwrap_or(ProviderContinuationCapability::HistoryReplayOnly);
|
||||
let configured_provider_continuation_state = effective_provider_config
|
||||
.as_ref()
|
||||
.map(|config| config.provider_continuation_state())
|
||||
.unwrap_or_else(ProviderContinuationState::history_replay_only);
|
||||
let restored_provider_continuation_state = load_previous_provider_continuation_state(
|
||||
db,
|
||||
session_id,
|
||||
provider_routing_snapshot.as_ref(),
|
||||
provider_continuation_capability,
|
||||
);
|
||||
let provider_continuation_state = if matches!(
|
||||
restored_provider_continuation_state,
|
||||
ProviderContinuationState::HistoryReplayOnly
|
||||
) {
|
||||
configured_provider_continuation_state
|
||||
} else {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 恢复上一条 terminal run 的 provider continuation: session_id={}, kind={}",
|
||||
session_id,
|
||||
restored_provider_continuation_state.kind()
|
||||
);
|
||||
restored_provider_continuation_state
|
||||
};
|
||||
turn_input_builder
|
||||
.set_provider_continuation_capability(provider_continuation_capability)
|
||||
.set_provider_continuation(provider_continuation_state);
|
||||
|
||||
let sandbox_outcome = apply_workspace_sandbox_permissions(
|
||||
state,
|
||||
config_manager,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
app,
|
||||
session_id,
|
||||
request.metadata.as_ref(),
|
||||
&workspace_root,
|
||||
runtime_chat_mode,
|
||||
requested_strategy,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("注入 workspace 安全策略失败: {e}"))?;
|
||||
|
||||
match sandbox_outcome {
|
||||
WorkspaceSandboxApplyOutcome::Applied { sandbox_type } => {
|
||||
tracing::info!(
|
||||
"[AsterAgent] 已启用 workspace 本地 sandbox: root={}, type={}",
|
||||
workspace_root,
|
||||
sandbox_type
|
||||
);
|
||||
}
|
||||
WorkspaceSandboxApplyOutcome::DisabledByConfig => {
|
||||
tracing::info!(
|
||||
"[AsterAgent] workspace 本地 sandbox 已关闭,继续使用普通执行模式: root={}",
|
||||
workspace_root
|
||||
);
|
||||
}
|
||||
WorkspaceSandboxApplyOutcome::UnavailableFallback {
|
||||
warning_message,
|
||||
notify_user,
|
||||
} => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] workspace 本地 sandbox 不可用,已降级为普通执行: root={}, warning={}",
|
||||
workspace_root,
|
||||
warning_message
|
||||
);
|
||||
if notify_user {
|
||||
let warning_event = TauriAgentEvent::Warning {
|
||||
code: Some(WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE.to_string()),
|
||||
message: warning_message,
|
||||
};
|
||||
if let Err(e) = app.emit(&request.event_name, &warning_event) {
|
||||
tracing::error!("[AsterAgent] 发送 sandbox 降级提醒失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tracker = ExecutionTracker::new(db.clone());
|
||||
let cancel_token = state.create_cancel_token(session_id).await;
|
||||
let auto_continue_metadata = auto_continue_config.clone();
|
||||
let request_metadata = request.metadata.clone();
|
||||
sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await;
|
||||
let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref());
|
||||
let run_observation = Arc::new(Mutex::new(ChatRunObservation::default()));
|
||||
let run_observation_for_finalize = run_observation.clone();
|
||||
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let runtime_snapshot = {
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
match agent.runtime_snapshot(session_id).await {
|
||||
Ok(snapshot) => Some(snapshot),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 提交 turn 前读取 runtime snapshot 失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let runtime_projection_snapshot =
|
||||
RuntimeProjectionSnapshot::from_snapshot(session_id, runtime_snapshot.as_ref());
|
||||
tracing::info!(
|
||||
"[AsterAgent] runtime_projection_snapshot={}",
|
||||
serde_json::to_string(&runtime_projection_snapshot).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
let resolved_thread_id = runtime_projection_snapshot
|
||||
.primary_thread_id()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| session_id.to_string());
|
||||
let resolved_turn_id = request
|
||||
.turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
let turn_state = TurnState::new(
|
||||
session_id,
|
||||
workspace_id.as_str(),
|
||||
resolved_thread_id.clone(),
|
||||
resolved_turn_id.clone(),
|
||||
requested_strategy.as_db_value(),
|
||||
effective_strategy.as_db_value(),
|
||||
TurnRequestToolPolicySnapshot::from(&request_tool_policy),
|
||||
include_context_trace,
|
||||
runtime_chat_mode_label(runtime_chat_mode),
|
||||
);
|
||||
tracing::info!(
|
||||
"[AsterAgent] turn_state={}",
|
||||
serde_json::to_string(&turn_state).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
turn_input_builder
|
||||
.set_thread_id(turn_state.thread_id.clone())
|
||||
.set_turn_id(turn_state.turn_id.clone());
|
||||
let turn_input_envelope = turn_input_builder.build();
|
||||
let turn_input_diagnostics = turn_input_envelope.diagnostics_snapshot();
|
||||
tracing::info!(
|
||||
"[AsterAgent] turn_input_envelope={}",
|
||||
serde_json::to_string(&turn_input_diagnostics).unwrap_or_else(|_| "{}".to_string())
|
||||
);
|
||||
|
||||
let mut run_start_metadata = build_chat_run_metadata_base(
|
||||
&request,
|
||||
workspace_id.as_str(),
|
||||
effective_strategy,
|
||||
&request_tool_policy,
|
||||
auto_continue_enabled,
|
||||
auto_continue_metadata.as_ref(),
|
||||
);
|
||||
if let Ok(session_state_value) = serde_json::to_value(&session_state_snapshot) {
|
||||
run_start_metadata.insert("session_state".to_string(), session_state_value);
|
||||
}
|
||||
if let Ok(runtime_projection_value) = serde_json::to_value(&runtime_projection_snapshot) {
|
||||
run_start_metadata.insert("runtime_projection".to_string(), runtime_projection_value);
|
||||
}
|
||||
if let Ok(turn_state_value) = serde_json::to_value(&turn_state) {
|
||||
run_start_metadata.insert("turn_state".to_string(), turn_state_value);
|
||||
}
|
||||
if let Ok(turn_input_value) = serde_json::to_value(&turn_input_diagnostics) {
|
||||
run_start_metadata.insert("turn_input".to_string(), turn_input_value);
|
||||
}
|
||||
let run_start_metadata_for_finalize = run_start_metadata.clone();
|
||||
let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create(
|
||||
db.clone(),
|
||||
turn_state.thread_id.clone(),
|
||||
turn_state.turn_id.clone(),
|
||||
request.message.clone(),
|
||||
)?));
|
||||
let runtime_status_session_config = {
|
||||
let mut session_config_builder = SessionConfigBuilder::new(session_id)
|
||||
.thread_id(turn_state.thread_id.clone())
|
||||
.turn_id(turn_state.turn_id.clone());
|
||||
if let Some(turn_context) = turn_input_envelope.turn_context_override() {
|
||||
session_config_builder = session_config_builder.turn_context(turn_context);
|
||||
}
|
||||
session_config_builder.build()
|
||||
};
|
||||
|
||||
// 获取 Agent Arc 并保持 guard 在整个流处理期间存活
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard.as_ref().ok_or("Agent not initialized")?;
|
||||
if let Err(error) = agent
|
||||
.ensure_runtime_turn_initialized(
|
||||
&runtime_status_session_config,
|
||||
Some(request.message.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses(
|
||||
&request,
|
||||
effective_strategy,
|
||||
&request_tool_policy,
|
||||
request
|
||||
.provider_config
|
||||
.as_ref()
|
||||
.map(|config| config.model_name.as_str()),
|
||||
);
|
||||
for status in [initial_runtime_status, decided_runtime_status] {
|
||||
emit_runtime_status_with_projection(
|
||||
agent,
|
||||
app,
|
||||
&request.event_name,
|
||||
&timeline_recorder,
|
||||
workspace_root.as_str(),
|
||||
&runtime_status_session_config,
|
||||
status,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let resolved_thread_id_for_session = turn_state.thread_id.clone();
|
||||
let resolved_turn_id_for_session = turn_state.turn_id.clone();
|
||||
let turn_input_envelope_for_session = turn_input_envelope.clone();
|
||||
|
||||
let build_session_config = || {
|
||||
let mut session_config_builder = SessionConfigBuilder::new(session_id)
|
||||
.thread_id(resolved_thread_id_for_session.clone())
|
||||
.turn_id(resolved_turn_id_for_session.clone());
|
||||
if let Some(prompt) = turn_input_envelope_for_session.system_prompt() {
|
||||
session_config_builder = session_config_builder.system_prompt(prompt.to_string());
|
||||
}
|
||||
if let Some(turn_context) = turn_input_envelope_for_session.turn_context_override() {
|
||||
session_config_builder = session_config_builder.turn_context(turn_context);
|
||||
}
|
||||
session_config_builder = session_config_builder
|
||||
.include_context_trace(turn_input_envelope_for_session.include_context_trace());
|
||||
session_config_builder.build()
|
||||
};
|
||||
|
||||
lime_agent::tools::set_skill_tool_session_access(session_id, model_skill_tool_enabled);
|
||||
let final_result = tracker
|
||||
.with_run_custom(
|
||||
RunSource::Chat,
|
||||
Some("agent_runtime_submit_turn".to_string()),
|
||||
Some(session_id.to_string()),
|
||||
Some(serde_json::Value::Object(run_start_metadata.clone())),
|
||||
async {
|
||||
let mut added_code_execution = false;
|
||||
if effective_strategy == AsterExecutionStrategy::CodeOrchestrated {
|
||||
added_code_execution = ensure_code_execution_extension_enabled(agent).await?;
|
||||
}
|
||||
|
||||
let primary_result = stream_reply_once(
|
||||
agent,
|
||||
app,
|
||||
&request.event_name,
|
||||
build_runtime_user_message(&request.message, request.images.as_deref()),
|
||||
Some(Path::new(&workspace_root)),
|
||||
build_session_config(),
|
||||
cancel_token.clone(),
|
||||
&request_tool_policy,
|
||||
{
|
||||
let run_observation = run_observation.clone();
|
||||
let app = app.clone();
|
||||
let event_name = request.event_name.clone();
|
||||
let timeline_recorder = timeline_recorder.clone();
|
||||
let workspace_root = workspace_root.clone();
|
||||
let request_metadata = request_metadata.clone();
|
||||
let provider_continuation_capability = provider_continuation_capability;
|
||||
move |event| {
|
||||
let mut observation = match run_observation.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] run observation lock poisoned,继续复用内部状态"
|
||||
);
|
||||
error.into_inner()
|
||||
}
|
||||
};
|
||||
observation.record_event(
|
||||
event,
|
||||
workspace_root.as_str(),
|
||||
request_metadata.as_ref(),
|
||||
provider_continuation_capability,
|
||||
);
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.record_runtime_event(
|
||||
&app,
|
||||
&event_name,
|
||||
event,
|
||||
workspace_root.as_str(),
|
||||
) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录时间线事件失败(已降级继续): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let run_result: Result<(), String> = match primary_result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(primary_error)
|
||||
if effective_strategy == AsterExecutionStrategy::CodeOrchestrated
|
||||
&& should_fallback_to_react_from_code_orchestrated(&primary_error) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 编排模式执行失败,自动降级到 ReAct: {}",
|
||||
primary_error.message
|
||||
);
|
||||
if added_code_execution {
|
||||
if let Err(e) =
|
||||
agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 降级前移除 code_execution 扩展失败: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
added_code_execution = false;
|
||||
}
|
||||
stream_reply_once(
|
||||
agent,
|
||||
&app,
|
||||
&request.event_name,
|
||||
build_runtime_user_message(
|
||||
&request.message,
|
||||
request.images.as_deref(),
|
||||
),
|
||||
Some(Path::new(&workspace_root)),
|
||||
build_session_config(),
|
||||
cancel_token.clone(),
|
||||
&request_tool_policy,
|
||||
{
|
||||
let run_observation = run_observation.clone();
|
||||
let app = app.clone();
|
||||
let event_name = request.event_name.clone();
|
||||
let timeline_recorder = timeline_recorder.clone();
|
||||
let workspace_root = workspace_root.clone();
|
||||
let request_metadata = request_metadata.clone();
|
||||
let provider_continuation_capability =
|
||||
provider_continuation_capability;
|
||||
move |event| {
|
||||
let mut observation = match run_observation.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] run observation lock poisoned,继续复用内部状态"
|
||||
);
|
||||
error.into_inner()
|
||||
}
|
||||
};
|
||||
observation.record_event(
|
||||
event,
|
||||
workspace_root.as_str(),
|
||||
request_metadata.as_ref(),
|
||||
provider_continuation_capability,
|
||||
);
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.record_runtime_event(
|
||||
&app,
|
||||
&event_name,
|
||||
event,
|
||||
workspace_root.as_str(),
|
||||
) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录时间线事件失败(已降级继续): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|fallback_err| fallback_err.message)
|
||||
}
|
||||
Err(primary_error) => Err(primary_error.message),
|
||||
};
|
||||
|
||||
if added_code_execution {
|
||||
if let Err(e) = agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 移除 code_execution 扩展失败,后续会话可能继续保留编排模式: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
run_result
|
||||
},
|
||||
move |result| {
|
||||
let observation = match run_observation_for_finalize.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] finalize run metadata 时 observation lock 已 poisoned"
|
||||
);
|
||||
error.into_inner().clone()
|
||||
}
|
||||
};
|
||||
let metadata =
|
||||
build_chat_run_finish_metadata(&run_start_metadata_for_finalize, &observation);
|
||||
|
||||
match result {
|
||||
Ok(_) => RunFinishDecision {
|
||||
status: lime_core::database::dao::agent_run::AgentRunStatus::Success,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
metadata: Some(metadata),
|
||||
},
|
||||
Err(err) => RunFinishDecision {
|
||||
status: lime_core::database::dao::agent_run::AgentRunStatus::Error,
|
||||
error_code: Some("chat_stream_failed".to_string()),
|
||||
error_message: Some(err.clone()),
|
||||
metadata: Some(metadata),
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
lime_agent::tools::clear_skill_tool_session_access(session_id);
|
||||
|
||||
match final_result {
|
||||
Ok(()) => {
|
||||
complete_runtime_status_projection(
|
||||
agent,
|
||||
app,
|
||||
&request.event_name,
|
||||
&timeline_recorder,
|
||||
workspace_root.as_str(),
|
||||
&runtime_status_session_config,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(error) = recorder.complete_turn_success(app, &request.event_name) {
|
||||
tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error);
|
||||
}
|
||||
}
|
||||
let done_event = TauriAgentEvent::FinalDone { usage: None };
|
||||
if let Err(e) = app.emit(&request.event_name, &done_event) {
|
||||
tracing::error!("[AsterAgent] 发送完成事件失败: {}", e);
|
||||
}
|
||||
emit_subagent_status_changed_events(app, session_id).await;
|
||||
}
|
||||
Err(e) => {
|
||||
complete_runtime_status_projection(
|
||||
agent,
|
||||
app,
|
||||
&request.event_name,
|
||||
&timeline_recorder,
|
||||
workspace_root.as_str(),
|
||||
&runtime_status_session_config,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let mut recorder = match timeline_recorder.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => error.into_inner(),
|
||||
};
|
||||
if let Err(timeline_error) = recorder.fail_turn(app, &request.event_name, &e) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 记录失败 turn 时间线失败(已降级继续): {}",
|
||||
timeline_error
|
||||
);
|
||||
}
|
||||
}
|
||||
let error_event = TauriAgentEvent::Error { message: e.clone() };
|
||||
if let Err(emit_err) = app.emit(&request.event_name, &error_event) {
|
||||
tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_err);
|
||||
}
|
||||
emit_subagent_status_changed_events(app, session_id).await;
|
||||
state.remove_cancel_token(session_id).await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理取消令牌
|
||||
state.remove_cancel_token(session_id).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_queued_turn_preview(message: &str) -> String {
|
||||
let compact = message.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if compact.is_empty() {
|
||||
return "空白输入".to_string();
|
||||
}
|
||||
|
||||
let preview = compact.chars().take(80).collect::<String>();
|
||||
if compact.chars().count() > 80 {
|
||||
format!("{preview}...")
|
||||
} else {
|
||||
preview
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_queued_turn_task(
|
||||
mut request: AsterChatRequest,
|
||||
) -> Result<QueuedTurnTask<serde_json::Value>, String> {
|
||||
let queued_turn_id = request
|
||||
.queued_turn_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
request.queued_turn_id = Some(queued_turn_id.clone());
|
||||
|
||||
let image_count = request
|
||||
.images
|
||||
.as_ref()
|
||||
.map(|images| images.len())
|
||||
.unwrap_or(0);
|
||||
let payload =
|
||||
serde_json::to_value(&request).map_err(|e| format!("序列化排队 turn 失败: {e}"))?;
|
||||
|
||||
Ok(QueuedTurnTask {
|
||||
queued_turn_id,
|
||||
session_id: request.session_id.clone(),
|
||||
event_name: request.event_name.clone(),
|
||||
message_preview: build_queued_turn_preview(&request.message),
|
||||
message_text: request.message.clone(),
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
image_count,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_queued_turn_request(payload: serde_json::Value) -> Result<AsterChatRequest, String> {
|
||||
serde_json::from_value(payload).map_err(|e| format!("反序列化排队 turn 失败: {e}"))
|
||||
}
|
||||
|
||||
pub(crate) fn build_runtime_queue_executor() -> RuntimeQueueExecutor {
|
||||
Arc::new(|context, payload| {
|
||||
async move {
|
||||
let request = deserialize_queued_turn_request(payload)?;
|
||||
execute_aster_chat_request(
|
||||
&context.app,
|
||||
&context.state,
|
||||
&context.db,
|
||||
&context.api_key_provider_service,
|
||||
&context.logs,
|
||||
&context.config_manager,
|
||||
&context.mcp_manager,
|
||||
&context.automation_state,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.boxed()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SessionProviderRoutingState {
|
||||
provider_selector: String,
|
||||
}
|
||||
|
||||
impl ExtensionState for SessionProviderRoutingState {
|
||||
const EXTENSION_NAME: &'static str = "lime_provider_routing";
|
||||
const VERSION: &'static str = "v0";
|
||||
}
|
||||
|
||||
impl SessionProviderRoutingState {
|
||||
fn new(provider_selector: impl Into<String>) -> Option<Self> {
|
||||
normalize_optional_text(Some(provider_selector.into()))
|
||||
.map(|provider_selector| Self { provider_selector })
|
||||
}
|
||||
|
||||
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
|
||||
<Self as ExtensionState>::from_extension_data(extension_data)
|
||||
}
|
||||
|
||||
fn from_session(session: &aster::session::Session) -> Option<Self> {
|
||||
Self::from_extension_data(&session.extension_data)
|
||||
}
|
||||
|
||||
fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> {
|
||||
<Self as ExtensionState>::to_extension_data(self, extension_data)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn into_updated_extension_data(
|
||||
self,
|
||||
session: &aster::session::Session,
|
||||
) -> Result<ExtensionData, String> {
|
||||
let mut extension_data = session.extension_data.clone();
|
||||
self.to_extension_data(&mut extension_data)?;
|
||||
Ok(extension_data)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_session_provider_routing(
|
||||
session_id: &str,
|
||||
provider_selector: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(state) = SessionProviderRoutingState::new(provider_selector.to_string()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let session = SessionManager::get_session(session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取会话 provider 路由上下文失败: {error}"))?;
|
||||
let extension_data = state.into_updated_extension_data(&session)?;
|
||||
SessionManager::update_session(session_id)
|
||||
.extension_data(extension_data)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|error| format!("持久化会话 provider 路由上下文失败: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_session_provider_selector(
|
||||
session: &aster::session::Session,
|
||||
) -> Option<String> {
|
||||
SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_runtime_session_internal(
|
||||
db: &DbConnection,
|
||||
working_dir: Option<String>,
|
||||
workspace_id: String,
|
||||
name: Option<String>,
|
||||
execution_strategy: Option<AsterExecutionStrategy>,
|
||||
) -> Result<String, String> {
|
||||
tracing::info!("[AsterAgent] 创建会话: name={:?}", name);
|
||||
|
||||
let workspace_id = workspace_id.trim().to_string();
|
||||
if workspace_id.is_empty() {
|
||||
return Err("workspace_id 必填,请先选择项目工作区".to_string());
|
||||
}
|
||||
|
||||
let manager = WorkspaceManager::new(db.clone());
|
||||
let workspace = manager
|
||||
.get(&workspace_id)
|
||||
.map_err(|e| format!("读取 workspace 失败: {e}"))?
|
||||
.ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?;
|
||||
let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?;
|
||||
let workspace_root = ensured.root_path.to_string_lossy().to_string();
|
||||
|
||||
if ensured.repaired {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] 会话创建阶段检测到 workspace 目录异常并已修复: {}{}",
|
||||
workspace_root,
|
||||
if ensured.relocated {
|
||||
"(已迁移)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let resolved_working_dir = working_dir
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| Some(workspace_root.clone()));
|
||||
|
||||
AsterAgentWrapper::create_session_sync(
|
||||
db,
|
||||
name,
|
||||
resolved_working_dir,
|
||||
workspace_id,
|
||||
Some(
|
||||
execution_strategy
|
||||
.unwrap_or(AsterExecutionStrategy::React)
|
||||
.as_db_value()
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn update_runtime_session_execution_strategy_internal(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
execution_strategy: AsterExecutionStrategy,
|
||||
) -> Result<(), String> {
|
||||
AsterAgentWrapper::update_session_execution_strategy_sync(
|
||||
db,
|
||||
session_id,
|
||||
execution_strategy.as_db_value(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn list_runtime_sessions_internal(
|
||||
db: &DbConnection,
|
||||
) -> Result<Vec<SessionInfo>, String> {
|
||||
tracing::info!("[AsterAgent] 列出会话");
|
||||
AsterAgentWrapper::list_sessions_sync(db)
|
||||
}
|
||||
|
||||
pub(crate) fn rename_runtime_session_internal(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
tracing::info!("[AsterAgent] 重命名会话: {}", session_id);
|
||||
AsterAgentWrapper::rename_session_sync(db, session_id, name)
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_runtime_session_internal(
|
||||
db: &DbConnection,
|
||||
session_id: &str,
|
||||
) -> Result<(), String> {
|
||||
tracing::info!("[AsterAgent] 删除会话: {}", session_id);
|
||||
AsterAgentWrapper::delete_session(db, session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
use super::*;
|
||||
|
||||
const SUBAGENT_RUNTIME_EVENT_PREFIX: &str = "agent_subagent_stream";
|
||||
const SUBAGENT_STATUS_EVENT_PREFIX: &str = "agent_subagent_status";
|
||||
const SUBAGENT_CONTROL_CLOSE_REASON: &str = "close_agent";
|
||||
const DEFAULT_WAIT_AGENT_TIMEOUT_MS: i64 = 30_000;
|
||||
const MIN_WAIT_AGENT_TIMEOUT_MS: i64 = 1_000;
|
||||
const MAX_WAIT_AGENT_TIMEOUT_MS: i64 = 300_000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SubagentStatusChangedEvent {
|
||||
#[serde(rename = "type")]
|
||||
event_type: &'static str,
|
||||
session_id: String,
|
||||
root_session_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parent_session_id: Option<String>,
|
||||
status: SubagentRuntimeStatusKind,
|
||||
}
|
||||
|
||||
pub(crate) struct SubagentControlRuntime {
|
||||
app_handle: AppHandle,
|
||||
state: AsterAgentState,
|
||||
pub(crate) db: DbConnection,
|
||||
api_key_provider_service: ApiKeyProviderServiceState,
|
||||
logs: LogState,
|
||||
config_manager: GlobalConfigManagerState,
|
||||
mcp_manager: McpManagerState,
|
||||
automation_state: AutomationServiceState,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SubagentControlRuntime {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SubagentControlRuntime")
|
||||
.field("app_handle", &"<tauri-app-handle>")
|
||||
.field("state", &"<aster-agent-state>")
|
||||
.field("db", &"<db-connection>")
|
||||
.field("api_key_provider_service", &"<api-key-provider-service>")
|
||||
.field("logs", &"<log-state>")
|
||||
.field("config_manager", &"<global-config-manager>")
|
||||
.field("mcp_manager", &"<mcp-manager>")
|
||||
.field("automation_state", &"<automation-state>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for SubagentControlRuntime {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
app_handle: self.app_handle.clone(),
|
||||
state: self.state.clone(),
|
||||
db: self.db.clone(),
|
||||
api_key_provider_service: ApiKeyProviderServiceState(
|
||||
self.api_key_provider_service.0.clone(),
|
||||
),
|
||||
logs: self.logs.clone(),
|
||||
config_manager: GlobalConfigManagerState(self.config_manager.0.clone()),
|
||||
mcp_manager: self.mcp_manager.clone(),
|
||||
automation_state: self.automation_state.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SubagentControlRuntime {
|
||||
pub(crate) fn new(
|
||||
app_handle: AppHandle,
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
api_key_provider_service: &ApiKeyProviderServiceState,
|
||||
logs: &LogState,
|
||||
config_manager: &GlobalConfigManagerState,
|
||||
mcp_manager: &McpManagerState,
|
||||
automation_state: &AutomationServiceState,
|
||||
) -> Self {
|
||||
Self {
|
||||
app_handle,
|
||||
state: state.clone(),
|
||||
db: db.clone(),
|
||||
api_key_provider_service: ApiKeyProviderServiceState(
|
||||
api_key_provider_service.0.clone(),
|
||||
),
|
||||
logs: logs.clone(),
|
||||
config_manager: GlobalConfigManagerState(config_manager.0.clone()),
|
||||
mcp_manager: mcp_manager.clone(),
|
||||
automation_state: automation_state.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_initialized(&self) -> Result<(), String> {
|
||||
self.state.init_agent_with_db(&self.db).await
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str, field_name: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
Err(format!("{field_name} 不能为空"))
|
||||
} else {
|
||||
Ok(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_optional_text(value: Option<String>) -> Option<String> {
|
||||
let trimmed = value?.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_whitespace(value: &str) -> String {
|
||||
value.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
let count = value.chars().count();
|
||||
if count <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
if max_chars <= 3 {
|
||||
return value.chars().take(max_chars).collect();
|
||||
}
|
||||
let truncated = value.chars().take(max_chars - 3).collect::<String>();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn build_subagent_task_summary(message: &str) -> Option<String> {
|
||||
let normalized = normalize_whitespace(message);
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(truncate_chars(&normalized, 120))
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_optional_vec(values: &[String]) -> Vec<String> {
|
||||
let mut normalized = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for value in values {
|
||||
let Some(item) = normalize_optional_text(Some(value.clone())) else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(item.clone()) {
|
||||
normalized.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
fn build_subagent_session_name(
|
||||
message: &str,
|
||||
agent_type: Option<&str>,
|
||||
profile_name: Option<&str>,
|
||||
) -> String {
|
||||
normalize_optional_text(agent_type.map(ToString::to_string))
|
||||
.or_else(|| normalize_optional_text(profile_name.map(ToString::to_string)))
|
||||
.or_else(|| build_subagent_task_summary(message))
|
||||
.unwrap_or_else(|| "子代理".to_string())
|
||||
}
|
||||
|
||||
fn resolve_subagent_role_hint(
|
||||
request: &AgentRuntimeSpawnSubagentRequest,
|
||||
customization: Option<&SubagentCustomizationState>,
|
||||
) -> Option<String> {
|
||||
normalize_optional_text(request.agent_type.clone())
|
||||
.or_else(|| customization.and_then(|state| state.profile_name.clone()))
|
||||
.or_else(|| customization.and_then(|state| state.role_key.clone()))
|
||||
}
|
||||
|
||||
fn build_local_subagent_skill_payload(
|
||||
directory: &str,
|
||||
) -> Result<(SubagentSkillSummary, SubagentSkillPromptBlock), String> {
|
||||
let inspection = crate::commands::skill_cmd::inspect_local_skill_for_app(
|
||||
"lime".to_string(),
|
||||
directory.to_string(),
|
||||
)
|
||||
.map_err(|error| format!("读取本地 skill 失败 `{directory}`: {error}"))?;
|
||||
let name = inspection
|
||||
.metadata
|
||||
.get("name")
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(directory)
|
||||
.to_string();
|
||||
let description = inspection
|
||||
.metadata
|
||||
.get("description")
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
let title = format!("local skill · {name} ({directory})");
|
||||
|
||||
Ok((
|
||||
SubagentSkillSummary {
|
||||
id: format!("local:{directory}"),
|
||||
name,
|
||||
description,
|
||||
source: Some("local".to_string()),
|
||||
directory: Some(directory.to_string()),
|
||||
},
|
||||
SubagentSkillPromptBlock {
|
||||
title,
|
||||
content: inspection.content,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn build_subagent_customization_state(
|
||||
request: &AgentRuntimeSpawnSubagentRequest,
|
||||
) -> Result<Option<SubagentCustomizationState>, String> {
|
||||
let profile_id = normalize_optional_text(request.profile_id.clone());
|
||||
let profile = profile_id
|
||||
.as_deref()
|
||||
.and_then(builtin_profile_descriptor_by_id);
|
||||
let team_preset_id = normalize_optional_text(request.team_preset_id.clone());
|
||||
let team_preset = team_preset_id
|
||||
.as_deref()
|
||||
.and_then(builtin_team_preset_descriptor_by_id);
|
||||
let mut skill_ids = profile
|
||||
.map(|descriptor| {
|
||||
descriptor
|
||||
.skill_ids
|
||||
.iter()
|
||||
.map(|skill_id| (*skill_id).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
skill_ids.extend(normalize_optional_vec(&request.skill_ids));
|
||||
let skill_ids = normalize_optional_vec(&skill_ids);
|
||||
let skill_directories = normalize_optional_vec(&request.skill_directories);
|
||||
|
||||
let mut skills = skill_ids
|
||||
.iter()
|
||||
.map(|skill_id| {
|
||||
summarize_builtin_skill(skill_id).unwrap_or(SubagentSkillSummary {
|
||||
id: skill_id.clone(),
|
||||
name: skill_id.clone(),
|
||||
description: None,
|
||||
source: Some("requested".to_string()),
|
||||
directory: None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for directory in &skill_directories {
|
||||
let (summary, _) = build_local_subagent_skill_payload(directory)?;
|
||||
skills.push(summary);
|
||||
}
|
||||
|
||||
let state = SubagentCustomizationState {
|
||||
profile_id,
|
||||
profile_name: normalize_optional_text(request.profile_name.clone())
|
||||
.or_else(|| profile.map(|descriptor| descriptor.name.to_string())),
|
||||
role_key: normalize_optional_text(request.role_key.clone())
|
||||
.or_else(|| profile.map(|descriptor| descriptor.role_key.to_string())),
|
||||
team_preset_id,
|
||||
theme: normalize_optional_text(request.theme.clone())
|
||||
.or_else(|| profile.map(|descriptor| descriptor.theme.to_string()))
|
||||
.or_else(|| team_preset.map(|descriptor| descriptor.theme.to_string())),
|
||||
output_contract: normalize_optional_text(request.output_contract.clone())
|
||||
.or_else(|| profile.map(|descriptor| descriptor.output_contract.to_string())),
|
||||
system_overlay: normalize_optional_text(request.system_overlay.clone())
|
||||
.or_else(|| profile.map(|descriptor| descriptor.system_overlay.to_string())),
|
||||
skill_ids,
|
||||
skills,
|
||||
};
|
||||
|
||||
if state.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(state))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_subagent_customization_system_prompt(
|
||||
customization: Option<&SubagentCustomizationState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(customization) = customization else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut local_skill_blocks = Vec::new();
|
||||
for skill in &customization.skills {
|
||||
let Some(directory) = skill.directory.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let (_, block) = build_local_subagent_skill_payload(directory)?;
|
||||
local_skill_blocks.push(block);
|
||||
}
|
||||
|
||||
Ok(build_subagent_customization_prompt(
|
||||
customization,
|
||||
&local_skill_blocks,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PreparedRuntimeSubagentSession {
|
||||
session: aster::session::Session,
|
||||
customization: Option<SubagentCustomizationState>,
|
||||
system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
fn build_subagent_runtime_event_name(session_id: &str) -> String {
|
||||
format!("{SUBAGENT_RUNTIME_EVENT_PREFIX}:{session_id}")
|
||||
}
|
||||
|
||||
fn build_subagent_status_event_name(session_id: &str) -> String {
|
||||
format!("{SUBAGENT_STATUS_EVENT_PREFIX}:{session_id}")
|
||||
}
|
||||
|
||||
fn parse_subagent_runtime_event_session_id(event_name: &str) -> Option<&str> {
|
||||
event_name
|
||||
.strip_prefix(SUBAGENT_RUNTIME_EVENT_PREFIX)
|
||||
.and_then(|rest| rest.strip_prefix(':'))
|
||||
}
|
||||
|
||||
fn should_emit_subagent_status_for_runtime_event(event: &TauriAgentEvent) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
TauriAgentEvent::ThreadStarted { .. }
|
||||
| TauriAgentEvent::TurnStarted { .. }
|
||||
| TauriAgentEvent::TurnCompleted { .. }
|
||||
| TauriAgentEvent::TurnFailed { .. }
|
||||
| TauriAgentEvent::QueueAdded { .. }
|
||||
| TauriAgentEvent::QueueRemoved { .. }
|
||||
| TauriAgentEvent::QueueStarted { .. }
|
||||
| TauriAgentEvent::QueueCleared { .. }
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_subagent_status_scope_session_ids(session_id: &str) -> Vec<String> {
|
||||
let mut scope_ids = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut current_session_id = session_id.to_string();
|
||||
|
||||
while seen.insert(current_session_id.clone()) {
|
||||
scope_ids.push(current_session_id.clone());
|
||||
|
||||
let session = match SessionManager::get_session(¤t_session_id, false).await {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Subagent] 解析 team 事件 scope 失败: session_id={}, error={}",
|
||||
current_session_id,
|
||||
error
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
let Some(metadata) = resolve_subagent_session_metadata(&session.extension_data) else {
|
||||
break;
|
||||
};
|
||||
let Some(parent_session_id) = normalize_optional_text(Some(metadata.parent_session_id))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
current_session_id = parent_session_id;
|
||||
}
|
||||
|
||||
scope_ids
|
||||
}
|
||||
|
||||
pub(crate) async fn emit_subagent_status_changed_events(app: &AppHandle, session_id: &str) {
|
||||
let status = match load_subagent_runtime_status(session_id).await {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Subagent] 读取 team runtime 状态失败: session_id={}, error={}",
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let scope_ids = list_subagent_status_scope_session_ids(session_id).await;
|
||||
let root_session_id = scope_ids
|
||||
.last()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| session_id.to_string());
|
||||
let event = SubagentStatusChangedEvent {
|
||||
event_type: "subagent_status_changed",
|
||||
session_id: session_id.to_string(),
|
||||
root_session_id,
|
||||
parent_session_id: scope_ids.get(1).cloned(),
|
||||
status: status.kind,
|
||||
};
|
||||
|
||||
for scope_session_id in scope_ids {
|
||||
if let Err(error) = app.emit(&build_subagent_status_event_name(&scope_session_id), &event) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent][Subagent] 发送 team 状态事件失败: scope_session_id={}, session_id={}, error={}",
|
||||
scope_session_id,
|
||||
session_id,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_emit_subagent_status_for_runtime_event(
|
||||
app: &AppHandle,
|
||||
event_name: &str,
|
||||
event: &TauriAgentEvent,
|
||||
) {
|
||||
let Some(session_id) = parse_subagent_runtime_event_session_id(event_name) else {
|
||||
return;
|
||||
};
|
||||
if !should_emit_subagent_status_for_runtime_event(event) {
|
||||
return;
|
||||
}
|
||||
emit_subagent_status_changed_events(app, session_id).await;
|
||||
}
|
||||
|
||||
fn resolve_action_scope_turn_id(parent_session_id: &str) -> Option<String> {
|
||||
let scope = aster::session_context::current_action_scope()?;
|
||||
if scope.session_id.as_deref() != Some(parent_session_id) {
|
||||
return None;
|
||||
}
|
||||
normalize_optional_text(scope.turn_id)
|
||||
}
|
||||
|
||||
fn resolve_workspace_id_for_working_dir(
|
||||
db: &DbConnection,
|
||||
working_dir: &Path,
|
||||
) -> Result<String, String> {
|
||||
let manager = WorkspaceManager::new(db.clone());
|
||||
manager
|
||||
.get_by_path(working_dir)
|
||||
.map_err(|error| format!("解析 workspace 失败: {error}"))?
|
||||
.map(|workspace| workspace.id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"无法根据 working_dir 解析 workspace: {}",
|
||||
working_dir.to_string_lossy()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_wait_timeout_ms(timeout_ms: Option<i64>) -> Result<i64, String> {
|
||||
match timeout_ms.unwrap_or(DEFAULT_WAIT_AGENT_TIMEOUT_MS) {
|
||||
value if value <= 0 => Err("timeout_ms 必须大于 0".to_string()),
|
||||
value => Ok(value.clamp(MIN_WAIT_AGENT_TIMEOUT_MS, MAX_WAIT_AGENT_TIMEOUT_MS)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn count_active_team_subagents(parent_session_id: &str) -> Result<usize, String> {
|
||||
let child_sessions = list_subagent_child_sessions(parent_session_id)
|
||||
.await
|
||||
.map_err(|error| format!("读取 team child sessions 失败: {error}"))?;
|
||||
let mut active_count = 0usize;
|
||||
|
||||
for child_session in child_sessions {
|
||||
let status = load_subagent_runtime_status(&child_session.id).await?;
|
||||
if subagent_counts_toward_team_limit(status.kind) {
|
||||
active_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(active_count)
|
||||
}
|
||||
|
||||
pub(crate) fn subagent_counts_toward_team_limit(status: SubagentRuntimeStatusKind) -> bool {
|
||||
!matches!(
|
||||
status,
|
||||
SubagentRuntimeStatusKind::Closed | SubagentRuntimeStatusKind::NotFound
|
||||
)
|
||||
}
|
||||
|
||||
async fn enforce_team_spawn_limits(parent_session_id: &str) -> Result<(), String> {
|
||||
let parent_session = SessionManager::get_session(parent_session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取父会话失败: {error}"))?;
|
||||
|
||||
if parent_session.session_type == SessionType::SubAgent {
|
||||
return Err(
|
||||
"当前子代理不允许继续创建新的子代理。请返回父会话,由主线程统一编排 team。".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let active_count = count_active_team_subagents(parent_session_id).await?;
|
||||
if active_count >= DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS {
|
||||
return Err(format!(
|
||||
"team 当前最多允许 {} 个活跃子代理并发执行;请先 close_agent 关闭已完成子代理,或复用已有子代理。",
|
||||
DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_stashed_queued_turns(
|
||||
existing: Vec<aster::session::QueuedTurnRuntime>,
|
||||
current: Vec<aster::session::QueuedTurnRuntime>,
|
||||
) -> Vec<aster::session::QueuedTurnRuntime> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut merged = Vec::new();
|
||||
for queued_turn in existing.into_iter().chain(current.into_iter()) {
|
||||
if seen.insert(queued_turn.queued_turn_id.clone()) {
|
||||
merged.push(queued_turn);
|
||||
}
|
||||
}
|
||||
merged.sort_by(|left, right| {
|
||||
left.created_at
|
||||
.cmp(&right.created_at)
|
||||
.then_with(|| left.queued_turn_id.cmp(&right.queued_turn_id))
|
||||
});
|
||||
merged
|
||||
}
|
||||
|
||||
async fn restore_stashed_subagent_queue(
|
||||
queued_turns: Vec<aster::session::QueuedTurnRuntime>,
|
||||
) -> Result<(), String> {
|
||||
if queued_turns.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let store = require_shared_thread_runtime_store()
|
||||
.map_err(|error| format!("读取 shared runtime store 失败: {error}"))?;
|
||||
for queued_turn in queued_turns {
|
||||
store
|
||||
.enqueue_turn(queued_turn)
|
||||
.await
|
||||
.map_err(|error| format!("恢复 subagent queued turn 失败: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inherit_subagent_provider(
|
||||
runtime: &SubagentControlRuntime,
|
||||
parent_session_id: &str,
|
||||
child_session_id: &str,
|
||||
model_override: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let parent_session = SessionManager::get_session(parent_session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取父会话 provider 信息失败: {error}"))?;
|
||||
let parent_provider_selector = resolve_session_provider_selector(&parent_session)
|
||||
.or_else(|| normalize_optional_text(parent_session.provider_name.clone()));
|
||||
|
||||
if let Some(mut provider_config) = runtime.state.get_provider_config().await {
|
||||
if let Some(model_name) = normalize_optional_text(model_override.map(ToString::to_string)) {
|
||||
provider_config.model_name = model_name;
|
||||
}
|
||||
if provider_config.provider_selector.is_none() {
|
||||
provider_config.provider_selector = parent_provider_selector.clone();
|
||||
}
|
||||
runtime
|
||||
.state
|
||||
.configure_provider(provider_config, child_session_id, &runtime.db)
|
||||
.await?;
|
||||
if let Some(provider_selector) = parent_provider_selector {
|
||||
persist_session_provider_routing(child_session_id, &provider_selector).await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let provider_selector = parent_provider_selector
|
||||
.ok_or_else(|| "当前 provider 未配置,且父会话缺少 provider_name".to_string())?;
|
||||
let model_name = normalize_optional_text(model_override.map(ToString::to_string))
|
||||
.or_else(|| {
|
||||
parent_session
|
||||
.model_config
|
||||
.as_ref()
|
||||
.and_then(|config| normalize_optional_text(Some(config.model_name.clone())))
|
||||
})
|
||||
.ok_or_else(|| "当前 provider 未配置,且父会话缺少 model_name".to_string())?;
|
||||
|
||||
runtime
|
||||
.state
|
||||
.configure_provider_from_pool(
|
||||
&runtime.db,
|
||||
&provider_selector,
|
||||
&model_name,
|
||||
child_session_id,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())?;
|
||||
persist_session_provider_routing(child_session_id, &provider_selector).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_runtime_subagent_session(
|
||||
runtime: &SubagentControlRuntime,
|
||||
request: &AgentRuntimeSpawnSubagentRequest,
|
||||
) -> Result<PreparedRuntimeSubagentSession, String> {
|
||||
let parent_session_id =
|
||||
normalize_required_text(&request.parent_session_id, "parent_session_id")?;
|
||||
let message = normalize_required_text(&request.message, "message")?;
|
||||
enforce_team_spawn_limits(&parent_session_id).await?;
|
||||
let parent_session = SessionManager::get_session(&parent_session_id, false)
|
||||
.await
|
||||
.map_err(|error| format!("读取父会话失败: {error}"))?;
|
||||
let customization = build_subagent_customization_state(request)?;
|
||||
let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?;
|
||||
let profile_name = customization
|
||||
.as_ref()
|
||||
.and_then(|state| state.profile_name.as_deref());
|
||||
let role_hint = resolve_subagent_role_hint(request, customization.as_ref());
|
||||
|
||||
let session = SessionManager::create_session(
|
||||
parent_session.working_dir.clone(),
|
||||
build_subagent_session_name(&message, request.agent_type.as_deref(), profile_name),
|
||||
SessionType::SubAgent,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("创建 subagent session 失败: {error}"))?;
|
||||
|
||||
if let Some(parent_metadata) =
|
||||
AsterAgentWrapper::get_persisted_session_metadata_sync(&runtime.db, &parent_session_id)?
|
||||
{
|
||||
if let Some(execution_strategy) =
|
||||
normalize_optional_text(parent_metadata.execution_strategy)
|
||||
{
|
||||
AsterAgentWrapper::update_session_execution_strategy_sync(
|
||||
&runtime.db,
|
||||
&session.id,
|
||||
&execution_strategy,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut metadata = SubagentSessionMetadata::new(parent_session_id.clone())
|
||||
.with_task_summary(build_subagent_task_summary(&message))
|
||||
.with_role_hint(role_hint.clone())
|
||||
.with_created_from_turn_id(resolve_action_scope_turn_id(&parent_session_id));
|
||||
metadata.origin_tool = "spawn_agent".to_string();
|
||||
let mut extension_data = session.extension_data.clone();
|
||||
metadata
|
||||
.to_extension_data(&mut extension_data)
|
||||
.map_err(|error| format!("持久化 subagent metadata 失败: {error}"))?;
|
||||
if let Some(customization_state) = customization.as_ref() {
|
||||
customization_state
|
||||
.to_extension_data(&mut extension_data)
|
||||
.map_err(|error| format!("持久化 subagent customization 失败: {error}"))?;
|
||||
}
|
||||
SessionManager::update_session(&session.id)
|
||||
.extension_data(extension_data)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|error| format!("写入 subagent session metadata 失败: {error}"))?;
|
||||
|
||||
inherit_subagent_provider(
|
||||
runtime,
|
||||
&parent_session_id,
|
||||
&session.id,
|
||||
request.model.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(PreparedRuntimeSubagentSession {
|
||||
session,
|
||||
customization,
|
||||
system_prompt,
|
||||
})
|
||||
}
|
||||
|
||||
fn spawn_subagent_turn_in_background(
|
||||
runtime: SubagentControlRuntime,
|
||||
request: AsterChatRequest,
|
||||
) -> Result<String, String> {
|
||||
let queued_task = build_queued_turn_task(request)?;
|
||||
let submission_id = queued_task.queued_turn_id.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = submit_runtime_turn_service(
|
||||
runtime.app_handle.clone(),
|
||||
&runtime.state,
|
||||
&runtime.db,
|
||||
&runtime.api_key_provider_service,
|
||||
&runtime.logs,
|
||||
&runtime.config_manager,
|
||||
&runtime.mcp_manager,
|
||||
&runtime.automation_state,
|
||||
queued_task,
|
||||
false,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[AsterAgent][Subagent] 后台启动子代理失败: {}", error);
|
||||
}
|
||||
});
|
||||
Ok(submission_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_runtime_spawn_subagent_internal(
|
||||
runtime: &SubagentControlRuntime,
|
||||
request: AgentRuntimeSpawnSubagentRequest,
|
||||
) -> Result<AgentRuntimeSpawnSubagentResponse, String> {
|
||||
runtime.ensure_initialized().await?;
|
||||
let PreparedRuntimeSubagentSession {
|
||||
session: child_session,
|
||||
customization,
|
||||
system_prompt,
|
||||
} = create_runtime_subagent_session(runtime, &request).await?;
|
||||
let child_session_id = child_session.id.clone();
|
||||
let workspace_id =
|
||||
resolve_workspace_id_for_working_dir(&runtime.db, child_session.working_dir.as_path())?;
|
||||
let _ = spawn_subagent_turn_in_background(
|
||||
runtime.clone(),
|
||||
AsterChatRequest {
|
||||
message: normalize_required_text(&request.message, "message")?,
|
||||
session_id: child_session_id.clone(),
|
||||
event_name: build_subagent_runtime_event_name(&child_session_id),
|
||||
images: None,
|
||||
provider_config: None,
|
||||
project_id: None,
|
||||
workspace_id,
|
||||
web_search: None,
|
||||
search_mode: None,
|
||||
execution_strategy: None,
|
||||
auto_continue: None,
|
||||
system_prompt,
|
||||
metadata: Some(serde_json::json!({
|
||||
"subagent": {
|
||||
"parent_session_id": request.parent_session_id,
|
||||
"agent_type": request.agent_type,
|
||||
"reasoning_effort": request.reasoning_effort,
|
||||
"fork_context": request.fork_context,
|
||||
"origin_tool": "spawn_agent",
|
||||
"profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()),
|
||||
"profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()),
|
||||
"role_key": customization.as_ref().and_then(|state| state.role_key.clone()),
|
||||
"team_preset_id": customization.as_ref().and_then(|state| state.team_preset_id.clone()),
|
||||
"theme": customization.as_ref().and_then(|state| state.theme.clone()),
|
||||
"output_contract": customization.as_ref().and_then(|state| state.output_contract.clone()),
|
||||
"skill_ids": customization.as_ref().map(|state| state.skill_ids.clone()).unwrap_or_default(),
|
||||
"skills": customization.as_ref().map(|state| state.skills.clone()).unwrap_or_default(),
|
||||
}
|
||||
})),
|
||||
turn_id: None,
|
||||
queue_if_busy: Some(false),
|
||||
queued_turn_id: None,
|
||||
},
|
||||
)?;
|
||||
emit_subagent_status_changed_events(&runtime.app_handle, &child_session_id).await;
|
||||
|
||||
Ok(AgentRuntimeSpawnSubagentResponse {
|
||||
agent_id: child_session_id,
|
||||
nickname: normalize_optional_text(Some(child_session.name)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_runtime_send_subagent_input_internal(
|
||||
runtime: &SubagentControlRuntime,
|
||||
request: AgentRuntimeSendSubagentInputRequest,
|
||||
) -> Result<AgentRuntimeSendSubagentInputResponse, String> {
|
||||
runtime.ensure_initialized().await?;
|
||||
let session_id = normalize_required_text(&request.id, "id")?;
|
||||
let message = normalize_required_text(&request.message, "message")?;
|
||||
let status = load_subagent_runtime_status(&session_id).await?;
|
||||
match status.kind {
|
||||
SubagentRuntimeStatusKind::NotFound => {
|
||||
return Err(format!("子代理不存在: {session_id}"));
|
||||
}
|
||||
SubagentRuntimeStatusKind::Closed => {
|
||||
return Err(format!("子代理已关闭,请先恢复: {session_id}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let (session, _) = read_subagent_control_state(&session_id).await?;
|
||||
let customization = SubagentCustomizationState::from_session(&session);
|
||||
let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?;
|
||||
if request.interrupt {
|
||||
let _ = runtime.state.cancel_session(&session_id).await;
|
||||
let _ = clear_runtime_queue_service(&runtime.app_handle, &session_id).await?;
|
||||
}
|
||||
|
||||
let workspace_id =
|
||||
resolve_workspace_id_for_working_dir(&runtime.db, session.working_dir.as_path())?;
|
||||
let queued_task = build_queued_turn_task(AsterChatRequest {
|
||||
message,
|
||||
session_id: session_id.clone(),
|
||||
event_name: build_subagent_runtime_event_name(&session_id),
|
||||
images: None,
|
||||
provider_config: None,
|
||||
project_id: None,
|
||||
workspace_id,
|
||||
web_search: None,
|
||||
search_mode: None,
|
||||
execution_strategy: None,
|
||||
auto_continue: None,
|
||||
system_prompt,
|
||||
metadata: Some(serde_json::json!({
|
||||
"subagent": {
|
||||
"origin_tool": "send_input",
|
||||
"interrupt": request.interrupt,
|
||||
"profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()),
|
||||
"profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()),
|
||||
"role_key": customization.as_ref().and_then(|state| state.role_key.clone()),
|
||||
"team_preset_id": customization.as_ref().and_then(|state| state.team_preset_id.clone()),
|
||||
"theme": customization.as_ref().and_then(|state| state.theme.clone()),
|
||||
"output_contract": customization.as_ref().and_then(|state| state.output_contract.clone()),
|
||||
"skill_ids": customization.as_ref().map(|state| state.skill_ids.clone()).unwrap_or_default(),
|
||||
"skills": customization.as_ref().map(|state| state.skills.clone()).unwrap_or_default(),
|
||||
}
|
||||
})),
|
||||
turn_id: None,
|
||||
queue_if_busy: Some(true),
|
||||
queued_turn_id: None,
|
||||
})?;
|
||||
let submission_id = queued_task.queued_turn_id.clone();
|
||||
submit_runtime_turn_service(
|
||||
runtime.app_handle.clone(),
|
||||
&runtime.state,
|
||||
&runtime.db,
|
||||
&runtime.api_key_provider_service,
|
||||
&runtime.logs,
|
||||
&runtime.config_manager,
|
||||
&runtime.mcp_manager,
|
||||
&runtime.automation_state,
|
||||
queued_task,
|
||||
true,
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await?;
|
||||
emit_subagent_status_changed_events(&runtime.app_handle, &session_id).await;
|
||||
|
||||
Ok(AgentRuntimeSendSubagentInputResponse { submission_id })
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_runtime_wait_subagents_internal(
|
||||
runtime: &SubagentControlRuntime,
|
||||
request: AgentRuntimeWaitSubagentsRequest,
|
||||
) -> Result<AgentRuntimeWaitSubagentsResponse, String> {
|
||||
runtime.ensure_initialized().await?;
|
||||
let ids = request
|
||||
.ids
|
||||
.into_iter()
|
||||
.map(|id| normalize_required_text(&id, "ids"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if ids.is_empty() {
|
||||
return Err("ids 不能为空".to_string());
|
||||
}
|
||||
|
||||
let timeout_ms = normalize_wait_timeout_ms(request.timeout_ms)?;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms as u64);
|
||||
loop {
|
||||
let mut final_statuses = HashMap::new();
|
||||
for id in &ids {
|
||||
let status = load_subagent_runtime_status(id).await?;
|
||||
if status.kind.is_final() {
|
||||
final_statuses.insert(id.clone(), status);
|
||||
}
|
||||
}
|
||||
if !final_statuses.is_empty() {
|
||||
return Ok(AgentRuntimeWaitSubagentsResponse {
|
||||
status: final_statuses,
|
||||
timed_out: false,
|
||||
});
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Ok(AgentRuntimeWaitSubagentsResponse {
|
||||
status: HashMap::new(),
|
||||
timed_out: true,
|
||||
});
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_runtime_resume_subagent_internal(
|
||||
runtime: &SubagentControlRuntime,
|
||||
request: AgentRuntimeResumeSubagentRequest,
|
||||
) -> Result<AgentRuntimeResumeSubagentResponse, String> {
|
||||
runtime.ensure_initialized().await?;
|
||||
let session_id = normalize_required_text(&request.id, "id")?;
|
||||
let current_status = load_subagent_runtime_status(&session_id).await?;
|
||||
if current_status.kind == SubagentRuntimeStatusKind::NotFound
|
||||
|| current_status.kind != SubagentRuntimeStatusKind::Closed
|
||||
{
|
||||
return Ok(AgentRuntimeResumeSubagentResponse {
|
||||
status: current_status,
|
||||
cascade_session_ids: Vec::new(),
|
||||
changed_session_ids: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let target_ids = list_subagent_cascade_session_ids(&session_id).await?;
|
||||
let cascade_session_ids = target_ids.clone();
|
||||
let mut changed_ids = Vec::new();
|
||||
for target_id in target_ids {
|
||||
let (session, control_state) = read_subagent_control_state(&target_id).await?;
|
||||
if !control_state.closed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stashed_queued_turns = control_state.stashed_queued_turns.clone();
|
||||
let mut next_state = control_state.opened();
|
||||
next_state.stashed_queued_turns.clear();
|
||||
write_subagent_control_state(&session, &next_state).await?;
|
||||
restore_stashed_subagent_queue(stashed_queued_turns.clone()).await?;
|
||||
if !stashed_queued_turns.is_empty() {
|
||||
let _ = resume_runtime_queue_if_needed_service(
|
||||
runtime.app_handle.clone(),
|
||||
&runtime.state,
|
||||
&runtime.db,
|
||||
&runtime.api_key_provider_service,
|
||||
&runtime.logs,
|
||||
&runtime.config_manager,
|
||||
&runtime.mcp_manager,
|
||||
&runtime.automation_state,
|
||||
target_id.clone(),
|
||||
build_runtime_queue_executor(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
changed_ids.push(target_id);
|
||||
}
|
||||
|
||||
for changed_id in &changed_ids {
|
||||
emit_subagent_status_changed_events(&runtime.app_handle, &changed_id).await;
|
||||
}
|
||||
|
||||
Ok(AgentRuntimeResumeSubagentResponse {
|
||||
status: load_subagent_runtime_status(&session_id).await?,
|
||||
cascade_session_ids,
|
||||
changed_session_ids: changed_ids,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_runtime_close_subagent_internal(
|
||||
runtime: &SubagentControlRuntime,
|
||||
request: AgentRuntimeCloseSubagentRequest,
|
||||
) -> Result<AgentRuntimeCloseSubagentResponse, String> {
|
||||
runtime.ensure_initialized().await?;
|
||||
let session_id = normalize_required_text(&request.id, "id")?;
|
||||
let previous_status = load_subagent_runtime_status(&session_id).await?;
|
||||
if matches!(
|
||||
previous_status.kind,
|
||||
SubagentRuntimeStatusKind::NotFound | SubagentRuntimeStatusKind::Closed
|
||||
) {
|
||||
return Ok(AgentRuntimeCloseSubagentResponse {
|
||||
previous_status,
|
||||
cascade_session_ids: Vec::new(),
|
||||
changed_session_ids: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let target_ids = list_subagent_cascade_session_ids(&session_id).await?;
|
||||
let cascade_session_ids = target_ids.clone();
|
||||
let mut changed_ids = Vec::new();
|
||||
for target_id in target_ids {
|
||||
let (session, control_state) = read_subagent_control_state(&target_id).await?;
|
||||
if control_state.closed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = runtime.state.cancel_session(&target_id).await;
|
||||
let cleared_queued_turns = clear_runtime_queue_service(&runtime.app_handle, &target_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let next_state = SubagentControlState::closed(
|
||||
Some(SUBAGENT_CONTROL_CLOSE_REASON.to_string()),
|
||||
merge_stashed_queued_turns(control_state.stashed_queued_turns, cleared_queued_turns),
|
||||
);
|
||||
write_subagent_control_state(&session, &next_state).await?;
|
||||
changed_ids.push(target_id);
|
||||
}
|
||||
|
||||
for changed_id in &changed_ids {
|
||||
emit_subagent_status_changed_events(&runtime.app_handle, &changed_id).await;
|
||||
}
|
||||
|
||||
Ok(AgentRuntimeCloseSubagentResponse {
|
||||
previous_status,
|
||||
cascade_session_ids,
|
||||
changed_session_ids: changed_ids,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
use super::*;
|
||||
|
||||
#[path = "tool_runtime/browser_tools.rs"]
|
||||
mod browser_tools;
|
||||
#[path = "tool_runtime/creation_tools.rs"]
|
||||
mod creation_tools;
|
||||
#[path = "tool_runtime/search_bridge.rs"]
|
||||
mod search_bridge;
|
||||
#[path = "tool_runtime/social_tools.rs"]
|
||||
pub(crate) mod social_tools;
|
||||
#[path = "tool_runtime/subagent_tools.rs"]
|
||||
mod subagent_tools;
|
||||
#[path = "tool_runtime/workspace_tools.rs"]
|
||||
mod workspace_tools;
|
||||
|
||||
pub(crate) use browser_tools::ensure_browser_mcp_tools_registered;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use browser_tools::LimeBrowserMcpTool;
|
||||
pub(crate) use creation_tools::ensure_creation_task_tools_registered;
|
||||
pub(crate) use search_bridge::ensure_tool_search_tool_registered;
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use search_bridge::ToolSearchBridgeTool;
|
||||
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,
|
||||
};
|
||||
use workspace_tools::WorkspaceSandboxedBashTool;
|
||||
#[cfg(test)]
|
||||
pub(crate) use workspace_tools::{
|
||||
encode_tool_result_for_harness_observability, normalize_params_for_durable_memory_support,
|
||||
normalize_shell_command_params, normalize_workspace_tool_permission_behavior,
|
||||
};
|
||||
|
||||
async fn resolve_agent_registry(
|
||||
state: &AsterAgentState,
|
||||
) -> Result<
|
||||
(
|
||||
Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
|
||||
Option<Arc<aster::agents::extension_manager::ExtensionManager>>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let agent_arc = state.get_agent_arc();
|
||||
let guard = agent_arc.read().await;
|
||||
let agent = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Agent not initialized".to_string())?;
|
||||
let registry_arc = agent.tool_registry().clone();
|
||||
let extension_manager = agent.extension_manager.clone();
|
||||
drop(guard);
|
||||
Ok((registry_arc, Some(extension_manager)))
|
||||
}
|
||||
|
||||
fn unregister_named_tools(registry: &mut aster::tools::ToolRegistry, tool_names: &[&str]) {
|
||||
for tool_name in tool_names {
|
||||
registry.unregister(tool_name);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_workspace_mode_native_tool_surface(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
surface: WorkspaceToolSurface,
|
||||
db: DbConnection,
|
||||
api_key_provider_service: Arc<ApiKeyProviderService>,
|
||||
app_handle: AppHandle,
|
||||
config_manager: Arc<GlobalConfigManager>,
|
||||
) {
|
||||
if surface.browser_assist {
|
||||
browser_tools::register_browser_mcp_tools_to_registry(registry);
|
||||
} else {
|
||||
browser_tools::unregister_browser_mcp_tools_from_registry(registry);
|
||||
}
|
||||
|
||||
if surface.creator {
|
||||
social_tools::register_social_image_tool_to_registry(registry, config_manager);
|
||||
creation_tools::register_creation_task_tools_to_registry(
|
||||
registry,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
app_handle,
|
||||
);
|
||||
} else {
|
||||
let creator_tools = creator_tool_names();
|
||||
unregister_named_tools(registry, &creator_tools);
|
||||
}
|
||||
}
|
||||
|
||||
/// 为指定工作区生成本地 sandbox 权限模板
|
||||
pub(crate) async fn apply_workspace_sandbox_permissions(
|
||||
state: &AsterAgentState,
|
||||
config_manager: &GlobalConfigManagerState,
|
||||
db: &DbConnection,
|
||||
api_key_provider_service: &ApiKeyProviderServiceState,
|
||||
logs: &LogState,
|
||||
mcp_manager: &McpManagerState,
|
||||
automation_state: &AutomationServiceState,
|
||||
app_handle: &AppHandle,
|
||||
session_id: &str,
|
||||
request_metadata: Option<&serde_json::Value>,
|
||||
workspace_root: &str,
|
||||
runtime_chat_mode: RuntimeChatMode,
|
||||
execution_strategy: AsterExecutionStrategy,
|
||||
) -> Result<WorkspaceSandboxApplyOutcome, String> {
|
||||
let workspace_root = workspace_root.trim();
|
||||
if workspace_root.is_empty() {
|
||||
return Err("workspace 根目录为空".to_string());
|
||||
}
|
||||
|
||||
let sandbox_policy = resolve_workspace_sandbox_policy(config_manager);
|
||||
let auto_mode = execution_strategy == AsterExecutionStrategy::Auto;
|
||||
let current_config = config_manager.config();
|
||||
let execution_policy_input = ToolExecutionResolverInput {
|
||||
persisted_policy: Some(¤t_config.agent.tool_execution),
|
||||
request_metadata,
|
||||
};
|
||||
let tool_surface = WorkspaceToolSurface {
|
||||
creator: runtime_chat_mode == RuntimeChatMode::Creator,
|
||||
browser_assist: is_browser_assist_enabled(request_metadata),
|
||||
};
|
||||
let mut sandboxed_bash_tool: Option<WorkspaceSandboxedBashTool> = None;
|
||||
let apply_outcome = if !sandbox_policy.enabled {
|
||||
WorkspaceSandboxApplyOutcome::DisabledByConfig
|
||||
} else {
|
||||
match WorkspaceSandboxedBashTool::new(
|
||||
workspace_root,
|
||||
should_auto_approve_tool_warnings("bash", auto_mode, execution_policy_input),
|
||||
) {
|
||||
Ok(tool) => {
|
||||
let sandbox_type = tool.sandbox_type().to_string();
|
||||
sandboxed_bash_tool = Some(tool);
|
||||
WorkspaceSandboxApplyOutcome::Applied { sandbox_type }
|
||||
}
|
||||
Err(reason) => {
|
||||
if sandbox_policy.strict {
|
||||
return Err(format!(
|
||||
"workspace 本地 sandbox 严格模式已启用,初始化失败: {reason}"
|
||||
));
|
||||
}
|
||||
WorkspaceSandboxApplyOutcome::UnavailableFallback {
|
||||
warning_message: build_workspace_sandbox_warning_message(&reason),
|
||||
notify_user: sandbox_policy.notify_on_fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut permissions =
|
||||
build_workspace_execution_permissions(WorkspaceExecutionPermissionInput {
|
||||
surface: tool_surface,
|
||||
workspace_root,
|
||||
auto_mode,
|
||||
execution_policy_input,
|
||||
});
|
||||
|
||||
if tool_surface.browser_assist {
|
||||
for tool_name in browser_tools::browser_mcp_tool_names() {
|
||||
permissions.push(ToolPermission {
|
||||
tool: tool_name,
|
||||
allowed: true,
|
||||
priority: 88,
|
||||
conditions: Vec::new(),
|
||||
parameter_restrictions: Vec::new(),
|
||||
scope: PermissionScope::Session,
|
||||
reason: Some("允许浏览器 MCP 兼容工具".to_string()),
|
||||
expires_at: None,
|
||||
metadata: HashMap::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
append_browser_assist_session_permissions(&mut permissions, session_id, request_metadata);
|
||||
|
||||
let (registry_arc, _) = resolve_agent_registry(state).await?;
|
||||
let mut registry = registry_arc.write().await;
|
||||
let mut permission_manager = ToolPermissionManager::new(None);
|
||||
for permission in permissions {
|
||||
permission_manager.add_permission(permission, PermissionScope::Session);
|
||||
}
|
||||
registry.set_permission_manager(Arc::new(permission_manager));
|
||||
|
||||
let task_manager = shared_task_manager();
|
||||
workspace_tools::register_workspace_runtime_tools(
|
||||
&mut registry,
|
||||
task_manager,
|
||||
should_auto_approve_tool_warnings("Task", auto_mode, execution_policy_input),
|
||||
sandboxed_bash_tool,
|
||||
);
|
||||
|
||||
let subagent_runtime = SubagentControlRuntime::new(
|
||||
app_handle.clone(),
|
||||
state,
|
||||
db,
|
||||
api_key_provider_service,
|
||||
logs,
|
||||
config_manager,
|
||||
mcp_manager,
|
||||
automation_state,
|
||||
);
|
||||
subagent_tools::register_subagent_runtime_tools(&mut registry, subagent_runtime);
|
||||
|
||||
sync_workspace_mode_native_tool_surface(
|
||||
&mut registry,
|
||||
tool_surface,
|
||||
db.clone(),
|
||||
api_key_provider_service.0.clone(),
|
||||
app_handle.clone(),
|
||||
config_manager.0.clone(),
|
||||
);
|
||||
workspace_tools::wrap_registry_native_tools_for_workspace_runtime(&mut registry);
|
||||
|
||||
Ok(apply_outcome)
|
||||
}
|
||||
|
||||
/// 图片输入
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageInput {
|
||||
pub data: String,
|
||||
pub media_type: String,
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LimeBrowserMcpTool {
|
||||
tool_name: String,
|
||||
action_name: String,
|
||||
description: String,
|
||||
input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
impl LimeBrowserMcpTool {
|
||||
fn new(
|
||||
tool_name: String,
|
||||
action_name: String,
|
||||
description: String,
|
||||
input_schema: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_name,
|
||||
action_name,
|
||||
description,
|
||||
input_schema,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_backend(params: &serde_json::Value) -> Option<BrowserBackendType> {
|
||||
let raw = params.get("backend")?.as_str()?.trim().to_ascii_lowercase();
|
||||
parse_browser_backend_hint(&raw)
|
||||
}
|
||||
|
||||
fn supports_cdp_direct_action(action_name: &str) -> bool {
|
||||
matches!(
|
||||
action_name.trim().to_ascii_lowercase().as_str(),
|
||||
"tabs_context_mcp"
|
||||
| "tabs_create_mcp"
|
||||
| "navigate"
|
||||
| "click"
|
||||
| "type"
|
||||
| "form_input"
|
||||
| "scroll"
|
||||
| "scroll_page"
|
||||
| "refresh_page"
|
||||
| "go_back"
|
||||
| "go_forward"
|
||||
| "get_page_info"
|
||||
| "read_page"
|
||||
| "get_page_text"
|
||||
| "read_console_messages"
|
||||
| "read_network_requests"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_backend(
|
||||
action_name: &str,
|
||||
params: &serde_json::Value,
|
||||
session_hint: Option<&BrowserAssistRuntimeHint>,
|
||||
) -> Option<BrowserBackendType> {
|
||||
if let Some(explicit_backend) = Self::parse_backend(params) {
|
||||
return Some(explicit_backend);
|
||||
}
|
||||
|
||||
match session_hint.and_then(|hint| hint.preferred_backend.clone()) {
|
||||
Some(BrowserBackendType::CdpDirect)
|
||||
if !Self::supports_cdp_direct_action(action_name) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_profile_key(params: &serde_json::Value, context: &ToolContext) -> Option<String> {
|
||||
if let Some(value) = params.get("profile_key").and_then(|v| v.as_str()) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
context
|
||||
.environment
|
||||
.get(BROWSER_PROFILE_KEY_ENV_KEYS[0])
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
context
|
||||
.environment
|
||||
.get(BROWSER_PROFILE_KEY_ENV_KEYS[1])
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_launch_url(action_name: &str, params: &serde_json::Value) -> Option<String> {
|
||||
let normalized = action_name.trim().to_ascii_lowercase();
|
||||
if normalized == "navigate"
|
||||
|| normalized.ends_with("navigate")
|
||||
|| normalized == "tabs_create_mcp"
|
||||
|| normalized.ends_with("tabs_create_mcp")
|
||||
|| normalized == "open_url"
|
||||
|| normalized.ends_with("open_url")
|
||||
{
|
||||
return params
|
||||
.get("url")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LimeBrowserMcpTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.tool_name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
self.input_schema.clone()
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
ToolOptions::new()
|
||||
.with_max_retries(1)
|
||||
.with_base_timeout(Duration::from_secs(90))
|
||||
.with_dynamic_timeout(false)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let session_hint = get_browser_assist_runtime_hint(&_context.session_id).await;
|
||||
let backend = Self::resolve_backend(&self.action_name, ¶ms, session_hint.as_ref());
|
||||
let profile_key = Self::extract_profile_key(¶ms, _context)
|
||||
.or_else(|| session_hint.as_ref().map(|hint| hint.profile_key.clone()));
|
||||
if let (Some(hint), Some(profile_key)) = (session_hint.as_ref(), profile_key.as_ref()) {
|
||||
if hint.auto_launch {
|
||||
let launch_url = Self::extract_launch_url(&self.action_name, ¶ms)
|
||||
.or_else(|| hint.launch_url.clone());
|
||||
ensure_managed_chrome_profile_global(profile_key.clone(), launch_url)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!("自动启动浏览器协助会话失败: {error}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let timeout_ms = params.get("timeout_ms").and_then(|v| v.as_u64());
|
||||
let request = BrowserActionRequest {
|
||||
profile_key,
|
||||
backend,
|
||||
action: self.action_name.clone(),
|
||||
args: params,
|
||||
timeout_ms,
|
||||
};
|
||||
|
||||
let result = browser_execute_action_global(request)
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("浏览器动作执行失败: {e}")))?;
|
||||
|
||||
let payload = serde_json::to_string_pretty(&result)
|
||||
.unwrap_or_else(|_| format!("{{\"success\": {}}}", result.success));
|
||||
let browser_session_metadata = if result.session_id.is_some() {
|
||||
result
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("browser_session"))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
Some(serde_json::json!({
|
||||
"session_id": result.session_id.clone(),
|
||||
"target_id": result.target_id.clone(),
|
||||
}))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if result.success {
|
||||
let mut tool_result = ToolResult::success(payload)
|
||||
.with_metadata("tool_family", serde_json::json!("browser"))
|
||||
.with_metadata("action", serde_json::json!(self.action_name))
|
||||
.with_metadata(
|
||||
"selected_backend",
|
||||
serde_json::json!(result.backend.clone()),
|
||||
)
|
||||
.with_metadata("attempt_count", serde_json::json!(result.attempts.len()))
|
||||
.with_metadata("attempts", serde_json::json!(result.attempts.clone()))
|
||||
.with_metadata("result", serde_json::json!(result.clone()));
|
||||
if let Some(browser_session) = browser_session_metadata {
|
||||
tool_result = tool_result.with_metadata("browser_session", browser_session);
|
||||
}
|
||||
Ok(tool_result)
|
||||
} else {
|
||||
let mut tool_result = ToolResult::error(
|
||||
result
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "浏览器动作执行失败".to_string()),
|
||||
)
|
||||
.with_metadata("tool_family", serde_json::json!("browser"))
|
||||
.with_metadata("action", serde_json::json!(self.action_name))
|
||||
.with_metadata(
|
||||
"selected_backend",
|
||||
serde_json::json!(result.backend.clone()),
|
||||
)
|
||||
.with_metadata("attempts", serde_json::json!(result.attempts.clone()))
|
||||
.with_metadata("result", serde_json::json!(result.clone()));
|
||||
if let Some(browser_session) = browser_session_metadata {
|
||||
tool_result = tool_result.with_metadata("browser_session", browser_session);
|
||||
}
|
||||
Ok(tool_result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn browser_mcp_tool_names() -> Vec<String> {
|
||||
let mut names = Vec::new();
|
||||
for tool in get_chrome_mcp_tools() {
|
||||
names.push(format!("{}{}", browser_runtime_tool_prefix(), tool.name));
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
pub(super) fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegistry) {
|
||||
let tool_defs = get_chrome_mcp_tools();
|
||||
for tool_def in tool_defs {
|
||||
for prefix in ["mcp__lime-browser__"] {
|
||||
let full_name = format!("{prefix}{}", tool_def.name);
|
||||
if registry.contains(&full_name) {
|
||||
continue;
|
||||
}
|
||||
let tool = LimeBrowserMcpTool::new(
|
||||
full_name,
|
||||
tool_def.name.clone(),
|
||||
tool_def.description.clone(),
|
||||
tool_def.input_schema.clone(),
|
||||
);
|
||||
registry.register(Box::new(tool));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn unregister_browser_mcp_tools_from_registry(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
) {
|
||||
for tool_name in browser_mcp_tool_names() {
|
||||
registry.unregister(&tool_name);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_browser_mcp_tools_registered(
|
||||
state: &AsterAgentState,
|
||||
) -> Result<(), String> {
|
||||
let (registry_arc, extension_manager) = resolve_agent_registry(state).await?;
|
||||
let mut registry = registry_arc.write().await;
|
||||
register_browser_mcp_tools_to_registry(&mut registry);
|
||||
search_bridge::register_tool_search_tool_to_registry(
|
||||
&mut registry,
|
||||
registry_arc.clone(),
|
||||
extension_manager,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
use super::*;
|
||||
|
||||
fn is_safe_relative_path(path: &Path) -> bool {
|
||||
if path.is_absolute() {
|
||||
return false;
|
||||
}
|
||||
!path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_output_relative_path(
|
||||
task_type: &str,
|
||||
output_path: Option<&str>,
|
||||
) -> Result<PathBuf, ToolError> {
|
||||
if let Some(raw) = output_path {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ToolError::invalid_params(
|
||||
"outputPath 不能为空字符串".to_string(),
|
||||
));
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
if !is_safe_relative_path(&candidate) {
|
||||
return Err(ToolError::invalid_params(
|
||||
"outputPath 必须是安全的相对路径,且不能包含 '..'".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(candidate);
|
||||
}
|
||||
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let suffix = uuid::Uuid::new_v4().simple().to_string();
|
||||
Ok(PathBuf::from(".lime")
|
||||
.join("tasks")
|
||||
.join(task_type)
|
||||
.join(format!("{timestamp}-{suffix}.json")))
|
||||
}
|
||||
|
||||
fn submit_creation_task_record(
|
||||
app_handle: &AppHandle,
|
||||
context: &ToolContext,
|
||||
task_type: &str,
|
||||
title: Option<String>,
|
||||
payload: serde_json::Value,
|
||||
output_path: Option<&str>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let output_rel_path = resolve_output_relative_path(task_type, output_path)?;
|
||||
let output_abs_path = context.working_directory.join(&output_rel_path);
|
||||
|
||||
let parent = output_abs_path
|
||||
.parent()
|
||||
.ok_or_else(|| ToolError::execution_failed("无法解析任务文件父目录".to_string()))?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| ToolError::execution_failed(format!("创建任务目录失败: {error}")))?;
|
||||
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
let task_record = serde_json::json!({
|
||||
"task_id": task_id,
|
||||
"task_type": task_type,
|
||||
"title": title,
|
||||
"payload": payload,
|
||||
"status": "pending_submit",
|
||||
"created_at": chrono::Utc::now().to_rfc3339()
|
||||
});
|
||||
let task_content =
|
||||
serde_json::to_string_pretty(&task_record).unwrap_or_else(|_| task_record.to_string());
|
||||
|
||||
std::fs::write(&output_abs_path, task_content.as_bytes())
|
||||
.map_err(|error| ToolError::execution_failed(format!("写入任务文件失败: {error}")))?;
|
||||
|
||||
let emitted_payload = serde_json::json!({
|
||||
"task_id": task_id,
|
||||
"task_type": task_type,
|
||||
"path": output_rel_path.to_string_lossy().to_string(),
|
||||
"absolute_path": output_abs_path.to_string_lossy().to_string()
|
||||
});
|
||||
if let Err(error) = app_handle.emit("lime://creation_task_submitted", &emitted_payload) {
|
||||
tracing::warn!(
|
||||
"[AsterAgent] creation_task_submitted 事件发送失败: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
let output_payload = serde_json::json!({
|
||||
"success": true,
|
||||
"task_id": task_id,
|
||||
"task_type": task_type,
|
||||
"path": output_rel_path.to_string_lossy().to_string(),
|
||||
"absolute_path": output_abs_path.to_string_lossy().to_string(),
|
||||
"record": task_record
|
||||
});
|
||||
let output = serde_json::to_string_pretty(&output_payload)
|
||||
.unwrap_or_else(|_| output_payload.to_string());
|
||||
Ok(ToolResult::success(output)
|
||||
.with_metadata("task_id", serde_json::json!(task_id))
|
||||
.with_metadata("task_type", serde_json::json!(task_type))
|
||||
.with_metadata("path", serde_json::json!(output_abs_path.to_string_lossy())))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BroadcastTaskInput {
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
audience: Option<String>,
|
||||
#[serde(default)]
|
||||
tone: Option<String>,
|
||||
#[serde(default)]
|
||||
duration_hint_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LimeCreateBroadcastTaskTool {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl LimeCreateBroadcastTaskTool {
|
||||
fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LimeCreateBroadcastTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
LIME_CREATE_BROADCAST_TASK_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"创建播客内容整理任务(broadcast_generate)。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": { "type": "string", "description": "可播报正文内容。" },
|
||||
"title": { "type": "string", "description": "任务标题(可选)。" },
|
||||
"audience": { "type": "string", "description": "目标听众(可选)。" },
|
||||
"tone": { "type": "string", "description": "语气风格(可选)。" },
|
||||
"durationHintMinutes": { "type": "integer", "minimum": 1, "maximum": 180, "description": "建议时长(分钟,可选)。" },
|
||||
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["broadcast", "task", "creation"],
|
||||
"allowed_callers": ["assistant", "skill"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: BroadcastTaskInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
|
||||
if input.content.trim().is_empty() {
|
||||
return Err(ToolError::invalid_params(
|
||||
"content 不能为空字符串".to_string(),
|
||||
));
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"content": input.content,
|
||||
"audience": input.audience,
|
||||
"tone": input.tone,
|
||||
"durationHintMinutes": input.duration_hint_minutes
|
||||
});
|
||||
submit_creation_task_record(
|
||||
&self.app_handle,
|
||||
context,
|
||||
"broadcast_generate",
|
||||
input.title,
|
||||
payload,
|
||||
input.output_path.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CoverTaskInput {
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
platform: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<String>,
|
||||
#[serde(default)]
|
||||
image_url: Option<String>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
#[serde(default)]
|
||||
remark: Option<String>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LimeCreateCoverTaskTool {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl LimeCreateCoverTaskTool {
|
||||
fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LimeCreateCoverTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
LIME_CREATE_COVER_TASK_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"创建封面生成任务记录(cover_generate)。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": { "type": "string", "description": "封面提示词。" },
|
||||
"title": { "type": "string", "description": "任务标题(可选)。" },
|
||||
"platform": { "type": "string", "description": "目标平台(可选)。" },
|
||||
"size": { "type": "string", "description": "尺寸(可选)。" },
|
||||
"imageUrl": { "type": "string", "description": "生成后的封面 URL(可选)。" },
|
||||
"status": { "type": "string", "description": "状态(成功/失败,可选)。" },
|
||||
"remark": { "type": "string", "description": "备注(可选)。" },
|
||||
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
|
||||
},
|
||||
"required": ["prompt"],
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["cover", "image", "task"],
|
||||
"allowed_callers": ["assistant", "skill"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: CoverTaskInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
|
||||
if input.prompt.trim().is_empty() {
|
||||
return Err(ToolError::invalid_params(
|
||||
"prompt 不能为空字符串".to_string(),
|
||||
));
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"prompt": input.prompt,
|
||||
"platform": input.platform,
|
||||
"size": input.size,
|
||||
"imageUrl": input.image_url,
|
||||
"status": input.status,
|
||||
"remark": input.remark
|
||||
});
|
||||
submit_creation_task_record(
|
||||
&self.app_handle,
|
||||
context,
|
||||
"cover_generate",
|
||||
input.title,
|
||||
payload,
|
||||
input.output_path.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResourceSearchTaskInput {
|
||||
resource_type: String,
|
||||
query: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
usage: Option<String>,
|
||||
#[serde(default)]
|
||||
count: Option<u32>,
|
||||
#[serde(default)]
|
||||
filters: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LimeCreateResourceSearchTaskTool {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl LimeCreateResourceSearchTaskTool {
|
||||
fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LimeCreateResourceSearchTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"创建资源检索任务(modal_resource_search)。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resourceType": { "type": "string", "description": "资源类型,例如 image/bgm/sfx。" },
|
||||
"query": { "type": "string", "description": "检索关键词。" },
|
||||
"title": { "type": "string", "description": "任务标题(可选)。" },
|
||||
"usage": { "type": "string", "description": "用途说明(可选)。" },
|
||||
"count": { "type": "integer", "minimum": 1, "maximum": 50, "description": "候选数量(可选)。" },
|
||||
"filters": { "type": "object", "description": "过滤条件(可选)。" },
|
||||
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
|
||||
},
|
||||
"required": ["resourceType", "query"],
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["resource", "search", "task"],
|
||||
"allowed_callers": ["assistant", "skill"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: ResourceSearchTaskInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
|
||||
if input.resource_type.trim().is_empty() || input.query.trim().is_empty() {
|
||||
return Err(ToolError::invalid_params(
|
||||
"resourceType/query 不能为空字符串".to_string(),
|
||||
));
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"resourceType": input.resource_type,
|
||||
"query": input.query,
|
||||
"usage": input.usage,
|
||||
"count": input.count,
|
||||
"filters": input.filters
|
||||
});
|
||||
submit_creation_task_record(
|
||||
&self.app_handle,
|
||||
context,
|
||||
"modal_resource_search",
|
||||
input.title,
|
||||
payload,
|
||||
input.output_path.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImageTaskInput {
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
style: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<String>,
|
||||
#[serde(default)]
|
||||
count: Option<u32>,
|
||||
#[serde(default)]
|
||||
usage: Option<String>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LimeCreateImageTaskTool {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl LimeCreateImageTaskTool {
|
||||
fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LimeCreateImageTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
LIME_CREATE_IMAGE_TASK_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"创建图片生成任务(image_generate)。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": { "type": "string", "description": "图像提示词。" },
|
||||
"title": { "type": "string", "description": "任务标题(可选)。" },
|
||||
"style": { "type": "string", "description": "风格(可选)。" },
|
||||
"size": { "type": "string", "description": "尺寸(可选)。" },
|
||||
"count": { "type": "integer", "minimum": 1, "maximum": 20, "description": "生成数量(可选)。" },
|
||||
"usage": { "type": "string", "description": "用途(可选)。" },
|
||||
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
|
||||
},
|
||||
"required": ["prompt"],
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["image", "task", "generation"],
|
||||
"allowed_callers": ["assistant", "skill"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: ImageTaskInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
|
||||
if input.prompt.trim().is_empty() {
|
||||
return Err(ToolError::invalid_params(
|
||||
"prompt 不能为空字符串".to_string(),
|
||||
));
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"prompt": input.prompt,
|
||||
"style": input.style,
|
||||
"size": input.size,
|
||||
"count": input.count,
|
||||
"usage": input.usage
|
||||
});
|
||||
submit_creation_task_record(
|
||||
&self.app_handle,
|
||||
context,
|
||||
"image_generate",
|
||||
input.title,
|
||||
payload,
|
||||
input.output_path.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UrlParseTaskInput {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
#[serde(default)]
|
||||
key_points: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
extract_status: Option<String>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LimeCreateUrlParseTaskTool {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl LimeCreateUrlParseTaskTool {
|
||||
fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LimeCreateUrlParseTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
LIME_CREATE_URL_PARSE_TASK_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"创建链接解析任务(url_parse)。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "type": "string", "description": "目标 URL。" },
|
||||
"title": { "type": "string", "description": "任务标题(可选)。" },
|
||||
"summary": { "type": "string", "description": "摘要(可选)。" },
|
||||
"keyPoints": { "type": "array", "items": { "type": "string" }, "description": "关键要点(可选)。" },
|
||||
"extractStatus": { "type": "string", "description": "提取状态(可选)。" },
|
||||
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["url", "parse", "task"],
|
||||
"allowed_callers": ["assistant", "skill"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: UrlParseTaskInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
|
||||
if input.url.trim().is_empty() {
|
||||
return Err(ToolError::invalid_params("url 不能为空字符串".to_string()));
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"url": input.url,
|
||||
"summary": input.summary,
|
||||
"keyPoints": input.key_points,
|
||||
"extractStatus": input.extract_status
|
||||
});
|
||||
submit_creation_task_record(
|
||||
&self.app_handle,
|
||||
context,
|
||||
"url_parse",
|
||||
input.title,
|
||||
payload,
|
||||
input.output_path.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TypesettingTaskInput {
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
target_platform: Option<String>,
|
||||
#[serde(default)]
|
||||
rules: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
output_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LimeCreateTypesettingTaskTool {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl LimeCreateTypesettingTaskTool {
|
||||
fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LimeCreateTypesettingTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
LIME_CREATE_TYPESETTING_TASK_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"创建排版优化任务(typesetting)。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": { "type": "string", "description": "待排版内容。" },
|
||||
"title": { "type": "string", "description": "任务标题(可选)。" },
|
||||
"targetPlatform": { "type": "string", "description": "目标平台(可选)。" },
|
||||
"rules": { "type": "object", "description": "排版规则(可选)。" },
|
||||
"outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" }
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["typesetting", "task", "text"],
|
||||
"allowed_callers": ["assistant", "skill"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: TypesettingTaskInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
|
||||
if input.content.trim().is_empty() {
|
||||
return Err(ToolError::invalid_params(
|
||||
"content 不能为空字符串".to_string(),
|
||||
));
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"content": input.content,
|
||||
"targetPlatform": input.target_platform,
|
||||
"rules": input.rules
|
||||
});
|
||||
submit_creation_task_record(
|
||||
&self.app_handle,
|
||||
context,
|
||||
"typesetting",
|
||||
input.title,
|
||||
payload,
|
||||
input.output_path.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LimeCreateVideoGenerationTaskTool {
|
||||
db: DbConnection,
|
||||
api_key_provider_service: Arc<ApiKeyProviderService>,
|
||||
}
|
||||
|
||||
impl LimeCreateVideoGenerationTaskTool {
|
||||
fn new(db: DbConnection, api_key_provider_service: Arc<ApiKeyProviderService>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
api_key_provider_service,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for LimeCreateVideoGenerationTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
LIME_CREATE_VIDEO_TASK_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"调用 Lime 视频任务服务,创建真实的视频生成任务。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"projectId": { "type": "string", "description": "项目 ID。" },
|
||||
"providerId": { "type": "string", "description": "视频服务 Provider ID。" },
|
||||
"model": { "type": "string", "description": "模型名。" },
|
||||
"prompt": { "type": "string", "description": "视频生成提示词。" },
|
||||
"aspectRatio": { "type": "string", "description": "画幅比例,例如 16:9、9:16。" },
|
||||
"resolution": { "type": "string", "description": "分辨率,例如 720p。" },
|
||||
"duration": { "type": "integer", "description": "时长(秒)。" },
|
||||
"imageUrl": { "type": "string", "description": "首帧图 URL(可选)。" },
|
||||
"endImageUrl": { "type": "string", "description": "末帧图 URL(可选)。" },
|
||||
"seed": { "type": "integer", "description": "随机种子(可选)。" },
|
||||
"generateAudio": { "type": "boolean", "description": "是否生成音频(可选)。" },
|
||||
"cameraFixed": { "type": "boolean", "description": "是否固定镜头(可选)。" }
|
||||
},
|
||||
"required": ["projectId", "providerId", "model", "prompt"],
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["video", "task", "generation"],
|
||||
"allowed_callers": ["assistant", "skill"],
|
||||
"input_examples": [
|
||||
{
|
||||
"projectId": "project-demo",
|
||||
"providerId": "volcengine",
|
||||
"model": "doubao-seedance-1-0-pro-250528",
|
||||
"prompt": "未来城市清晨,镜头缓慢推进,电影感",
|
||||
"aspectRatio": "16:9",
|
||||
"duration": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let request: CreateVideoGenerationRequest = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
|
||||
if request.project_id.trim().is_empty()
|
||||
|| request.provider_id.trim().is_empty()
|
||||
|| request.model.trim().is_empty()
|
||||
|| request.prompt.trim().is_empty()
|
||||
{
|
||||
return Err(ToolError::invalid_params(
|
||||
"projectId/providerId/model/prompt 均不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let service = VideoGenerationService::new();
|
||||
let created = service
|
||||
.create_task(&self.db, self.api_key_provider_service.as_ref(), request)
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("创建视频任务失败: {error}")))?;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"success": true,
|
||||
"task": created
|
||||
});
|
||||
let output = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
|
||||
Ok(ToolResult::success(output))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn register_creation_task_tools_to_registry(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
db: DbConnection,
|
||||
api_key_provider_service: Arc<ApiKeyProviderService>,
|
||||
app_handle: AppHandle,
|
||||
) {
|
||||
if !registry.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME) {
|
||||
registry.register(Box::new(LimeCreateVideoGenerationTaskTool::new(
|
||||
db.clone(),
|
||||
api_key_provider_service.clone(),
|
||||
)));
|
||||
}
|
||||
if !registry.contains(LIME_CREATE_BROADCAST_TASK_TOOL_NAME) {
|
||||
registry.register(Box::new(LimeCreateBroadcastTaskTool::new(
|
||||
app_handle.clone(),
|
||||
)));
|
||||
}
|
||||
if !registry.contains(LIME_CREATE_COVER_TASK_TOOL_NAME) {
|
||||
registry.register(Box::new(LimeCreateCoverTaskTool::new(app_handle.clone())));
|
||||
}
|
||||
if !registry.contains(LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME) {
|
||||
registry.register(Box::new(LimeCreateResourceSearchTaskTool::new(
|
||||
app_handle.clone(),
|
||||
)));
|
||||
}
|
||||
if !registry.contains(LIME_CREATE_IMAGE_TASK_TOOL_NAME) {
|
||||
registry.register(Box::new(LimeCreateImageTaskTool::new(app_handle.clone())));
|
||||
}
|
||||
if !registry.contains(LIME_CREATE_URL_PARSE_TASK_TOOL_NAME) {
|
||||
registry.register(Box::new(LimeCreateUrlParseTaskTool::new(
|
||||
app_handle.clone(),
|
||||
)));
|
||||
}
|
||||
if !registry.contains(LIME_CREATE_TYPESETTING_TASK_TOOL_NAME) {
|
||||
registry.register(Box::new(LimeCreateTypesettingTaskTool::new(app_handle)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_creation_task_tools_registered(
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
api_key_provider_service: &ApiKeyProviderServiceState,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let (registry_arc, _) = resolve_agent_registry(state).await?;
|
||||
let mut registry = registry_arc.write().await;
|
||||
register_creation_task_tools_to_registry(
|
||||
&mut registry,
|
||||
db.clone(),
|
||||
api_key_provider_service.0.clone(),
|
||||
app_handle.clone(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) struct ToolSearchBridgeTool {
|
||||
registry: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
|
||||
extension_manager: Option<Arc<aster::agents::extension_manager::ExtensionManager>>,
|
||||
}
|
||||
|
||||
impl ToolSearchBridgeTool {
|
||||
pub(crate) fn new(
|
||||
registry: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
|
||||
extension_manager: Option<Arc<aster::agents::extension_manager::ExtensionManager>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
extension_manager,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_input_examples_in_schema(
|
||||
schema: &serde_json::Value,
|
||||
input_examples: &[serde_json::Value],
|
||||
) -> serde_json::Value {
|
||||
if input_examples.is_empty() {
|
||||
return schema.clone();
|
||||
}
|
||||
|
||||
let mut enriched = schema.clone();
|
||||
let Some(root) = enriched.as_object_mut() else {
|
||||
return schema.clone();
|
||||
};
|
||||
let extension = root
|
||||
.entry("x-lime".to_string())
|
||||
.or_insert_with(|| serde_json::json!({}));
|
||||
let Some(extension_obj) = extension.as_object_mut() else {
|
||||
return schema.clone();
|
||||
};
|
||||
if extension_obj.get("input_examples").is_none()
|
||||
&& extension_obj.get("inputExamples").is_none()
|
||||
{
|
||||
extension_obj.insert(
|
||||
"input_examples".to_string(),
|
||||
serde_json::Value::Array(input_examples.to_vec()),
|
||||
);
|
||||
}
|
||||
enriched
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_schema_metadata(
|
||||
tool_name: &str,
|
||||
schema: &serde_json::Value,
|
||||
) -> (
|
||||
bool, // deferred_loading
|
||||
bool, // always_visible
|
||||
Vec<String>, // allowed_callers
|
||||
Vec<String>, // tags
|
||||
Vec<serde_json::Value>, // input_examples
|
||||
) {
|
||||
let metadata = lime_core::tool_calling::extract_tool_surface_metadata(tool_name, schema);
|
||||
|
||||
(
|
||||
metadata.deferred_loading.unwrap_or(false),
|
||||
metadata.always_visible.unwrap_or(false),
|
||||
metadata.allowed_callers.unwrap_or_default(),
|
||||
metadata.tags.unwrap_or_default(),
|
||||
metadata.input_examples,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn score_match(name: &str, description: &str, tags: &[String], query: &str) -> i32 {
|
||||
lime_core::tool_calling::score_tool_match(name, description, tags, query)
|
||||
}
|
||||
|
||||
pub(crate) fn extension_tool_status(
|
||||
extension_configs: &[ExtensionConfig],
|
||||
visible_extension_tools: &HashSet<String>,
|
||||
tool_name: &str,
|
||||
) -> (&'static str, bool, Option<String>) {
|
||||
let status = resolve_extension_tool_runtime_status(
|
||||
extension_configs,
|
||||
visible_extension_tools,
|
||||
tool_name,
|
||||
);
|
||||
(
|
||||
status.status,
|
||||
status.deferred_loading,
|
||||
status.extension_name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ToolSearchBridgeTool {
|
||||
fn name(&self) -> &str {
|
||||
TOOL_SEARCH_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"统一搜索当前会话工具面:包含原生 registry 工具与 extension/MCP 工具。对 deferred 工具会返回加载提示。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "工具名称/描述关键词" },
|
||||
"caller": { "type": "string", "description": "调用方,例如 assistant/code_execution" },
|
||||
"limit": { "type": "integer", "minimum": 1, "maximum": 100 },
|
||||
"include_deferred": { "type": "boolean", "description": "是否包含延迟加载工具" },
|
||||
"include_schema": { "type": "boolean", "description": "是否返回完整输入 schema" }
|
||||
},
|
||||
"required": []
|
||||
})
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
ToolOptions::new()
|
||||
.with_max_retries(1)
|
||||
.with_base_timeout(Duration::from_secs(15))
|
||||
.with_dynamic_timeout(false)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let query = params
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let caller = params
|
||||
.get("caller")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("assistant")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let include_deferred = params
|
||||
.get("include_deferred")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let include_schema = params
|
||||
.get("include_schema")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.clamp(1, 100) as usize)
|
||||
.unwrap_or(10);
|
||||
|
||||
let registry = self.registry.read().await;
|
||||
let definitions = registry.get_definitions();
|
||||
|
||||
let mut scored = definitions
|
||||
.into_iter()
|
||||
.filter(|d| d.name != self.name())
|
||||
.filter_map(|definition| {
|
||||
let metadata = lime_core::tool_calling::extract_tool_surface_metadata(
|
||||
&definition.name,
|
||||
&definition.input_schema,
|
||||
);
|
||||
if !lime_core::tool_calling::tool_visible_in_context(&metadata, include_deferred) {
|
||||
return None;
|
||||
}
|
||||
if !lime_core::tool_calling::tool_matches_caller(&metadata, Some(&caller)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let deferred_loading = metadata.deferred_loading.unwrap_or(false);
|
||||
let always_visible = metadata.always_visible.unwrap_or(false);
|
||||
let allowed_callers = metadata.allowed_callers.unwrap_or_default();
|
||||
let tags = metadata.tags.unwrap_or_default();
|
||||
let input_examples = metadata.input_examples;
|
||||
let score =
|
||||
Self::score_match(&definition.name, &definition.description, &tags, &query);
|
||||
if score <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let item = if include_schema {
|
||||
let enriched_schema = Self::with_input_examples_in_schema(
|
||||
&definition.input_schema,
|
||||
&input_examples,
|
||||
);
|
||||
serde_json::json!({
|
||||
"source": "native_registry",
|
||||
"name": definition.name,
|
||||
"description": definition.description,
|
||||
"input_schema": enriched_schema,
|
||||
"deferred_loading": deferred_loading,
|
||||
"always_visible": always_visible,
|
||||
"allowed_callers": allowed_callers,
|
||||
"input_examples": input_examples,
|
||||
"tags": tags
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"source": "native_registry",
|
||||
"name": definition.name,
|
||||
"description": definition.description,
|
||||
"deferred_loading": deferred_loading,
|
||||
"always_visible": always_visible,
|
||||
"allowed_callers": allowed_callers,
|
||||
"input_examples": input_examples,
|
||||
"tags": tags
|
||||
})
|
||||
};
|
||||
Some((score, item))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
drop(registry);
|
||||
|
||||
if let Some(extension_manager) = self.extension_manager.as_ref() {
|
||||
let visible_extension_tools = extension_manager
|
||||
.get_prefixed_tools(None)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tool| tool.name.to_string())
|
||||
.collect::<HashSet<_>>();
|
||||
let extension_configs = extension_manager.get_extension_configs().await;
|
||||
let extension_tools = extension_manager
|
||||
.get_prefixed_tools_for_search(None)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for tool in extension_tools {
|
||||
if tool.name.as_ref() == self.name() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tool_name = tool.name.to_string();
|
||||
let description = tool.description.as_deref().unwrap_or("").to_string();
|
||||
let score = Self::score_match(&tool_name, &description, &[], &query);
|
||||
if score <= 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (status, deferred_loading, extension_name) = Self::extension_tool_status(
|
||||
&extension_configs,
|
||||
&visible_extension_tools,
|
||||
&tool_name,
|
||||
);
|
||||
let input_schema = serde_json::Value::Object((*tool.input_schema).clone());
|
||||
let activation = if deferred_loading {
|
||||
serde_json::json!({
|
||||
"tool": "extensionmanager__load_tools",
|
||||
"arguments": {
|
||||
"tool_names": [tool_name.clone()]
|
||||
}
|
||||
})
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
};
|
||||
|
||||
let item = if include_schema {
|
||||
serde_json::json!({
|
||||
"source": "extension",
|
||||
"name": tool_name,
|
||||
"description": description,
|
||||
"extension_name": extension_name,
|
||||
"input_schema": input_schema,
|
||||
"deferred_loading": deferred_loading,
|
||||
"status": status,
|
||||
"activation": activation
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"source": "extension",
|
||||
"name": tool_name,
|
||||
"description": description,
|
||||
"extension_name": extension_name,
|
||||
"deferred_loading": deferred_loading,
|
||||
"status": status,
|
||||
"activation": activation
|
||||
})
|
||||
};
|
||||
scored.push((score, item));
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort_by(|(a_score, a_item), (b_score, b_item)| {
|
||||
b_score.cmp(a_score).then_with(|| {
|
||||
a_item["name"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.cmp(b_item["name"].as_str().unwrap_or_default())
|
||||
})
|
||||
});
|
||||
|
||||
let result = scored
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|(_, item)| item)
|
||||
.collect::<Vec<_>>();
|
||||
let text = serde_json::to_string_pretty(&serde_json::json!({
|
||||
"query": query,
|
||||
"caller": caller,
|
||||
"count": result.len(),
|
||||
"tools": result
|
||||
}))
|
||||
.map_err(|e| ToolError::execution_failed(format!("tool_search 序列化失败: {e}")))?;
|
||||
|
||||
Ok(ToolResult::success(text))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn register_tool_search_tool_to_registry(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
registry_arc: Arc<tokio::sync::RwLock<aster::tools::ToolRegistry>>,
|
||||
extension_manager: Option<Arc<aster::agents::extension_manager::ExtensionManager>>,
|
||||
) {
|
||||
if registry.contains(TOOL_SEARCH_TOOL_NAME) {
|
||||
return;
|
||||
}
|
||||
registry.register(Box::new(ToolSearchBridgeTool::new(
|
||||
registry_arc,
|
||||
extension_manager,
|
||||
)));
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_tool_search_tool_registered(
|
||||
state: &AsterAgentState,
|
||||
) -> Result<(), String> {
|
||||
let (registry_arc, extension_manager) = resolve_agent_registry(state).await?;
|
||||
let mut registry = registry_arc.write().await;
|
||||
register_tool_search_tool_to_registry(&mut registry, registry_arc.clone(), extension_manager);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SocialGenerateCoverImageTool {
|
||||
config_manager: Arc<GlobalConfigManager>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl SocialGenerateCoverImageTool {
|
||||
fn new(config_manager: Arc<GlobalConfigManager>) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(180))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self {
|
||||
config_manager,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_server_host(host: &str) -> String {
|
||||
let trimmed = host.trim();
|
||||
if trimmed.is_empty() || trimmed == "0.0.0.0" || trimmed == "::" {
|
||||
return "127.0.0.1".to_string();
|
||||
}
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
if trimmed.contains(':') {
|
||||
return format!("[{trimmed}]");
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_non_empty_string(
|
||||
params: &serde_json::Value,
|
||||
key: &str,
|
||||
default: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if let Some(value) = params.get(key).and_then(|v| v.as_str()) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
default.map(ToString::to_string)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_first_image_payload(
|
||||
response_body: &serde_json::Value,
|
||||
) -> Result<(Option<String>, Option<String>, Option<String>), String> {
|
||||
let data = response_body
|
||||
.get("data")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| "图像接口返回缺少 data 字段".to_string())?;
|
||||
|
||||
let first = data
|
||||
.first()
|
||||
.ok_or_else(|| "图像接口返回 data 为空".to_string())?;
|
||||
|
||||
let image_url = first
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let image_b64 = first
|
||||
.get("b64_json")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let revised_prompt = first
|
||||
.get("revised_prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok((image_url, image_b64, revised_prompt))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SocialGenerateCoverImageTool {
|
||||
fn name(&self) -> &str {
|
||||
SOCIAL_IMAGE_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"为社媒文章生成封面图,内部复用 Lime 的 /v1/images/generations 能力。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "图片描述词,建议包含主体、风格、氛围、构图。"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "可选模型名;不传则使用默认图像模型。"
|
||||
},
|
||||
"size": {
|
||||
"type": "string",
|
||||
"description": "图片尺寸,例如 1024x1024、1024x1792。"
|
||||
},
|
||||
"response_format": {
|
||||
"type": "string",
|
||||
"enum": ["url", "b64_json"],
|
||||
"description": "返回格式,默认 url。"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["image", "social-media", "cover"],
|
||||
"allowed_callers": ["assistant", "skill"],
|
||||
"input_examples": [
|
||||
{
|
||||
"prompt": "科技感蓝紫渐变背景,一位年轻创作者在笔记本前沉思,暖色轮廓光,简洁社媒封面风格",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
ToolOptions::new()
|
||||
.with_max_retries(1)
|
||||
.with_base_timeout(Duration::from_secs(180))
|
||||
.with_dynamic_timeout(false)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let prompt = Self::parse_non_empty_string(¶ms, "prompt", None).ok_or_else(|| {
|
||||
ToolError::invalid_params("参数 prompt 必填,且不能为空字符串".to_string())
|
||||
})?;
|
||||
|
||||
let runtime_config = self.config_manager.config();
|
||||
let model =
|
||||
Self::parse_non_empty_string(¶ms, "model", Some(SOCIAL_IMAGE_DEFAULT_MODEL))
|
||||
.unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_MODEL.to_string());
|
||||
let size = Self::parse_non_empty_string(
|
||||
¶ms,
|
||||
"size",
|
||||
runtime_config.image_gen.default_size.as_deref(),
|
||||
)
|
||||
.unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_SIZE.to_string());
|
||||
let response_format = Self::parse_non_empty_string(
|
||||
¶ms,
|
||||
"response_format",
|
||||
Some(SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT),
|
||||
)
|
||||
.unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT.to_string());
|
||||
|
||||
if response_format != "url" && response_format != "b64_json" {
|
||||
return Err(ToolError::invalid_params(
|
||||
"response_format 仅支持 url 或 b64_json".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let server_host = Self::normalize_server_host(&runtime_config.server.host);
|
||||
let endpoint = format!(
|
||||
"http://{}:{}/v1/images/generations",
|
||||
server_host, runtime_config.server.port
|
||||
);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"response_format": response_format
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", runtime_config.server.api_key),
|
||||
)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("调用图像接口失败: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("图像接口响应解析失败: {e}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
let error_message = response_body
|
||||
.get("error")
|
||||
.and_then(|v| v.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("图像生成失败")
|
||||
.to_string();
|
||||
let error_code = response_body
|
||||
.get("error")
|
||||
.and_then(|v| v.get("code"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("image_generation_failed")
|
||||
.to_string();
|
||||
let result_payload = serde_json::json!({
|
||||
"success": false,
|
||||
"error_code": error_code,
|
||||
"error_message": error_message,
|
||||
"status": status.as_u16(),
|
||||
"retryable": status.is_server_error() || status.as_u16() == 429
|
||||
});
|
||||
return Ok(ToolResult::error(result_payload.to_string())
|
||||
.with_metadata("result", result_payload));
|
||||
}
|
||||
|
||||
let (image_url, image_b64, revised_prompt) =
|
||||
Self::extract_first_image_payload(&response_body)
|
||||
.map_err(ToolError::execution_failed)?;
|
||||
|
||||
if image_url.is_none() && image_b64.is_none() {
|
||||
return Err(ToolError::execution_failed(
|
||||
"图像接口返回中未找到 url 或 b64_json".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let result_payload = serde_json::json!({
|
||||
"success": true,
|
||||
"image_url": image_url,
|
||||
"b64_json": image_b64,
|
||||
"revised_prompt": revised_prompt,
|
||||
"model": request_body.get("model").cloned(),
|
||||
"size": request_body.get("size").cloned(),
|
||||
"response_format": request_body.get("response_format").cloned()
|
||||
});
|
||||
let output = serde_json::to_string_pretty(&result_payload)
|
||||
.unwrap_or_else(|_| result_payload.to_string());
|
||||
Ok(ToolResult::success(output).with_metadata("result", result_payload))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn register_social_image_tool_to_registry(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
config_manager: Arc<GlobalConfigManager>,
|
||||
) {
|
||||
if registry.contains(SOCIAL_IMAGE_TOOL_NAME) {
|
||||
return;
|
||||
}
|
||||
registry.register(Box::new(SocialGenerateCoverImageTool::new(config_manager)));
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_social_image_tool_registered(
|
||||
state: &AsterAgentState,
|
||||
config_manager: &GlobalConfigManagerState,
|
||||
) -> Result<(), String> {
|
||||
let (registry_arc, _) = resolve_agent_registry(state).await?;
|
||||
let mut registry = registry_arc.write().await;
|
||||
register_social_image_tool_to_registry(&mut registry, config_manager.0.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn social_generate_cover_image_cmd(
|
||||
config_manager: State<'_, GlobalConfigManagerState>,
|
||||
prompt: String,
|
||||
size: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
if prompt.trim().is_empty() {
|
||||
return Err("prompt 不能为空".to_string());
|
||||
}
|
||||
let runtime_config = config_manager.config();
|
||||
let server_host =
|
||||
SocialGenerateCoverImageTool::normalize_server_host(&runtime_config.server.host);
|
||||
let size = size
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or(runtime_config.image_gen.default_size.as_deref())
|
||||
.unwrap_or(SOCIAL_IMAGE_DEFAULT_SIZE)
|
||||
.to_string();
|
||||
let endpoint = format!(
|
||||
"http://{}:{}/v1/images/generations",
|
||||
server_host, runtime_config.server.port
|
||||
);
|
||||
let request_body = serde_json::json!({
|
||||
"prompt": prompt.trim(),
|
||||
"model": SOCIAL_IMAGE_DEFAULT_MODEL,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"response_format": "url"
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(180))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
|
||||
let response = client
|
||||
.post(&endpoint)
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", runtime_config.server.api_key),
|
||||
)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("调用图像接口失败: {e}"))?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("图像接口响应解析失败: {e}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = response_body
|
||||
.get("error")
|
||||
.and_then(|v| v.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("图像生成失败");
|
||||
return Err(msg.to_string());
|
||||
}
|
||||
|
||||
let (image_url, _b64, _revised) =
|
||||
SocialGenerateCoverImageTool::extract_first_image_payload(&response_body)?;
|
||||
|
||||
image_url.ok_or_else(|| "接口返回中未找到 image_url".to_string())
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
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
|
||||
}
|
||||
|
||||
fn extract_tauri_message_text(message: &TauriMessage) -> Option<String> {
|
||||
let parts = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|content| match content {
|
||||
TauriMessageContent::Text { text } => {
|
||||
let trimmed = text.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
TauriMessageContent::ToolResponse {
|
||||
output, success, ..
|
||||
} if *success => {
|
||||
let trimmed = output.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Option<String> {
|
||||
detail
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|message| message.role == "assistant")
|
||||
.and_then(extract_tauri_message_text)
|
||||
.or_else(|| {
|
||||
detail.items.iter().rev().find_map(|item| {
|
||||
match &item.payload {
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::TurnSummary {
|
||||
text,
|
||||
}
|
||||
| lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Plan { text }
|
||||
| lime_core::database::dao::agent_timeline::AgentThreadItemPayload::AgentMessage {
|
||||
text,
|
||||
..
|
||||
}
|
||||
| lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Reasoning {
|
||||
text,
|
||||
..
|
||||
} => {
|
||||
let trimmed = text.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error {
|
||||
message,
|
||||
} => {
|
||||
let trimmed = message.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
lime_core::database::dao::agent_timeline::AgentThreadItemPayload::SubagentActivity {
|
||||
summary,
|
||||
..
|
||||
} => summary
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
detail
|
||||
.turns
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|turn| turn.error_message.clone())
|
||||
.map(|message| message.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
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 的历史提示词或旧技能;内部会退化为串行的 spawn_agent + wait_agent,不适合作为新的多代理并发主路径。新实现优先直接使用 spawn_agent / send_input / wait_agent / resume_agent / close_agent。"
|
||||
}
|
||||
|
||||
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),
|
||||
agent_type: Some(role.to_string()),
|
||||
model: input.model.clone(),
|
||||
reasoning_effort: None,
|
||||
fork_context: false,
|
||||
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,
|
||||
},
|
||||
)
|
||||
.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,
|
||||
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": "subagent_task->spawn_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())))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SpawnAgentToolInput {
|
||||
message: String,
|
||||
agent_type: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
fork_context: Option<bool>,
|
||||
profile_id: Option<String>,
|
||||
profile_name: Option<String>,
|
||||
role_key: Option<String>,
|
||||
#[serde(default)]
|
||||
skill_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
skill_directories: Vec<String>,
|
||||
team_preset_id: Option<String>,
|
||||
theme: Option<String>,
|
||||
system_overlay: Option<String>,
|
||||
output_contract: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SpawnAgentTool {
|
||||
runtime: SubagentControlRuntime,
|
||||
}
|
||||
|
||||
impl SpawnAgentTool {
|
||||
fn new(runtime: SubagentControlRuntime) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SpawnAgentTool {
|
||||
fn name(&self) -> &str {
|
||||
"spawn_agent"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"仅在任务需要拆成多个独立子范围、并行评审/验证,或用户明确要求多代理时使用。先判断当前关键路径:如果下一步立即依赖结果,不要把阻塞工作委派出去;优先把可并行推进的 sidecar 子任务交给子代理,同时主线程继续做不重叠的工作。创建真实子代理会话,并异步开始执行首条任务。不要对简单任务创建子代理;多个子代理必须分工明确,避免修改同一片文件;当前 team runtime 默认不允许子代理继续创建新的子代理。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "发送给子代理的首条任务消息。应是边界清晰、可独立完成、不会与其他并发子代理写入范围重叠的子任务。"
|
||||
},
|
||||
"agentType": {
|
||||
"type": "string",
|
||||
"description": "子代理角色提示,例如 explorer/planner/executor,也可以是 Image #1 这类展示标签"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "可选模型覆盖"
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": "string",
|
||||
"description": "保留字段,当前仅记录到 metadata"
|
||||
},
|
||||
"forkContext": {
|
||||
"type": "boolean",
|
||||
"description": "保留字段,当前仅记录到 metadata"
|
||||
},
|
||||
"profileId": {
|
||||
"type": "string",
|
||||
"description": "可选内置 profile id,例如 code-explorer / code-executor / code-verifier"
|
||||
},
|
||||
"profileName": {
|
||||
"type": "string",
|
||||
"description": "可选 profile 展示名称,用于 Team Workspace 与子代理 prompt"
|
||||
},
|
||||
"roleKey": {
|
||||
"type": "string",
|
||||
"description": "可选角色键,例如 explorer / executor / verifier / researcher"
|
||||
},
|
||||
"skillIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "可选 builtin skill id 列表,用于附加子代理技能提示"
|
||||
},
|
||||
"skillDirectories": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "可选本地已安装 skill 目录名;会读取对应 SKILL.md 注入子代理 prompt"
|
||||
},
|
||||
"teamPresetId": {
|
||||
"type": "string",
|
||||
"description": "可选 team preset id,例如 code-triage-team / research-team / content-creation-team"
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"description": "可选子代理主题标签,用于 GUI 展示与 prompt 约束"
|
||||
},
|
||||
"systemOverlay": {
|
||||
"type": "string",
|
||||
"description": "附加给该子代理的额外系统约束"
|
||||
},
|
||||
"outputContract": {
|
||||
"type": "string",
|
||||
"description": "要求子代理遵循的输出契约"
|
||||
}
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: SpawnAgentToolInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("spawn_agent 参数无效: {error}")))?;
|
||||
let response = agent_runtime_spawn_subagent_internal(
|
||||
&self.runtime,
|
||||
AgentRuntimeSpawnSubagentRequest {
|
||||
parent_session_id: context.session_id.clone(),
|
||||
message: input.message,
|
||||
agent_type: input.agent_type,
|
||||
model: input.model,
|
||||
reasoning_effort: input.reasoning_effort,
|
||||
fork_context: input.fork_context.unwrap_or(false),
|
||||
profile_id: input.profile_id,
|
||||
profile_name: input.profile_name,
|
||||
role_key: input.role_key,
|
||||
skill_ids: input.skill_ids,
|
||||
skill_directories: input.skill_directories,
|
||||
team_preset_id: input.team_preset_id,
|
||||
theme: input.theme,
|
||||
system_overlay: input.system_overlay,
|
||||
output_contract: input.output_contract,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ToolError::execution_failed)?;
|
||||
|
||||
Ok(
|
||||
ToolResult::success(format!("子代理已创建: {}", response.agent_id)).with_metadata(
|
||||
"spawn_agent",
|
||||
serde_json::to_value(&response).unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SendInputToolInput {
|
||||
id: String,
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
interrupt: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SendInputTool {
|
||||
runtime: SubagentControlRuntime,
|
||||
}
|
||||
|
||||
impl SendInputTool {
|
||||
fn new(runtime: SubagentControlRuntime) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SendInputTool {
|
||||
fn name(&self) -> &str {
|
||||
"send_input"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"向已存在的子代理追加输入。对强依赖既有上下文的后续任务,优先复用已有子代理而不是重复 spawn;interrupt=true 时会先中断当前执行并清空旧队列。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "子代理 session id"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "要发送给子代理的输入"
|
||||
},
|
||||
"interrupt": {
|
||||
"type": "boolean",
|
||||
"description": "是否先中断当前执行"
|
||||
}
|
||||
},
|
||||
"required": ["id", "message"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: SendInputToolInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("send_input 参数无效: {error}")))?;
|
||||
let response = agent_runtime_send_subagent_input_internal(
|
||||
&self.runtime,
|
||||
AgentRuntimeSendSubagentInputRequest {
|
||||
id: input.id,
|
||||
message: input.message,
|
||||
interrupt: input.interrupt,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ToolError::execution_failed)?;
|
||||
|
||||
Ok(
|
||||
ToolResult::success(format!("子代理输入已提交: {}", response.submission_id))
|
||||
.with_metadata(
|
||||
"send_input",
|
||||
serde_json::to_value(&response).unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct WaitAgentToolInput {
|
||||
ids: Vec<String>,
|
||||
#[serde(default, alias = "timeoutMs")]
|
||||
timeout_ms: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WaitAgentTool {
|
||||
runtime: SubagentControlRuntime,
|
||||
}
|
||||
|
||||
impl WaitAgentTool {
|
||||
fn new(runtime: SubagentControlRuntime) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WaitAgentTool {
|
||||
fn name(&self) -> &str {
|
||||
"wait_agent"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"等待一个或多个子代理进入最终状态。只有在主线程确实被结果阻塞、下一步必须依赖这些结果时才调用;可以同时等待多个 id,任一子代理先完成就会返回。不要反复机械 wait,优先在等待前继续做不重叠的本地工作;timeout_ms 应与任务规模匹配,避免过短轮询。"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "要等待的子代理 session id 列表"
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "最长等待时间(毫秒)"
|
||||
}
|
||||
},
|
||||
"required": ["ids"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
ToolOptions::new()
|
||||
.with_max_retries(0)
|
||||
.with_base_timeout(Duration::from_secs(310))
|
||||
.with_dynamic_timeout(false)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: WaitAgentToolInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("wait_agent 参数无效: {error}")))?;
|
||||
let response = agent_runtime_wait_subagents_internal(
|
||||
&self.runtime,
|
||||
AgentRuntimeWaitSubagentsRequest {
|
||||
ids: input.ids,
|
||||
timeout_ms: input.timeout_ms,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ToolError::execution_failed)?;
|
||||
let summary = if response.timed_out {
|
||||
"wait_agent 超时,未观测到最终状态".to_string()
|
||||
} else {
|
||||
format!("已观测到 {} 个子代理进入最终状态", response.status.len())
|
||||
};
|
||||
|
||||
Ok(ToolResult::success(summary).with_metadata(
|
||||
"wait_agent",
|
||||
serde_json::to_value(&response).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ResumeAgentToolInput {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResumeAgentTool {
|
||||
runtime: SubagentControlRuntime,
|
||||
}
|
||||
|
||||
impl ResumeAgentTool {
|
||||
fn new(runtime: SubagentControlRuntime) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ResumeAgentTool {
|
||||
fn name(&self) -> &str {
|
||||
"resume_agent"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"恢复之前关闭的子代理;若子代理未关闭则返回当前状态"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "子代理 session id"
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: ResumeAgentToolInput = serde_json::from_value(params).map_err(|error| {
|
||||
ToolError::invalid_params(format!("resume_agent 参数无效: {error}"))
|
||||
})?;
|
||||
let response = agent_runtime_resume_subagent_internal(
|
||||
&self.runtime,
|
||||
AgentRuntimeResumeSubagentRequest { id: input.id },
|
||||
)
|
||||
.await
|
||||
.map_err(ToolError::execution_failed)?;
|
||||
|
||||
let changed_count = response.changed_session_ids.len();
|
||||
let success_message = if changed_count > 1 {
|
||||
format!("子代理已恢复,并级联恢复 {changed_count} 个会话")
|
||||
} else if changed_count == 1 {
|
||||
"子代理已恢复".to_string()
|
||||
} else {
|
||||
format!("子代理当前状态: {:?}", response.status.kind)
|
||||
};
|
||||
|
||||
Ok(ToolResult::success(success_message).with_metadata(
|
||||
"resume_agent",
|
||||
serde_json::to_value(&response).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct CloseAgentToolInput {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CloseAgentTool {
|
||||
runtime: SubagentControlRuntime,
|
||||
}
|
||||
|
||||
impl CloseAgentTool {
|
||||
fn new(runtime: SubagentControlRuntime) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for CloseAgentTool {
|
||||
fn name(&self) -> &str {
|
||||
"close_agent"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"关闭子代理并级联关闭其子树;历史保留,可后续恢复"
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "子代理 session id"
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input: CloseAgentToolInput = serde_json::from_value(params)
|
||||
.map_err(|error| ToolError::invalid_params(format!("close_agent 参数无效: {error}")))?;
|
||||
let response = agent_runtime_close_subagent_internal(
|
||||
&self.runtime,
|
||||
AgentRuntimeCloseSubagentRequest { id: input.id },
|
||||
)
|
||||
.await
|
||||
.map_err(ToolError::execution_failed)?;
|
||||
|
||||
let changed_count = response.changed_session_ids.len();
|
||||
let success_message = if changed_count > 1 {
|
||||
format!(
|
||||
"子代理已关闭,并级联关闭 {changed_count} 个会话;关闭前状态: {:?}",
|
||||
response.previous_status.kind
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"子代理已关闭,关闭前状态: {:?}",
|
||||
response.previous_status.kind
|
||||
)
|
||||
};
|
||||
|
||||
Ok(ToolResult::success(success_message).with_metadata(
|
||||
"close_agent",
|
||||
serde_json::to_value(&response).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn register_subagent_runtime_tools(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
runtime: SubagentControlRuntime,
|
||||
) {
|
||||
registry.register(Box::new(SubAgentTaskTool::new(runtime.clone())));
|
||||
registry.register(Box::new(SpawnAgentTool::new(runtime.clone())));
|
||||
registry.register(Box::new(SendInputTool::new(runtime.clone())));
|
||||
registry.register(Box::new(WaitAgentTool::new(runtime.clone())));
|
||||
registry.register(Box::new(ResumeAgentTool::new(runtime.clone())));
|
||||
registry.register(Box::new(CloseAgentTool::new(runtime)));
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) struct WorkspaceSandboxedBashTool {
|
||||
delegate: BashTool,
|
||||
sandbox_type_name: String,
|
||||
base_sandbox_config: ProcessSandboxConfig,
|
||||
auto_approve_warnings: bool,
|
||||
}
|
||||
|
||||
impl WorkspaceSandboxedBashTool {
|
||||
pub(super) fn new(workspace_root: &str, auto_approve_warnings: bool) -> Result<Self, String> {
|
||||
let workspace_root = workspace_root.trim();
|
||||
if workspace_root.is_empty() {
|
||||
return Err("workspace 根目录为空".to_string());
|
||||
}
|
||||
|
||||
let sandbox_type = detect_best_sandbox();
|
||||
let sandbox_type_name = format!("{sandbox_type:?}");
|
||||
if sandbox_type_name == "None" {
|
||||
return Err(format!(
|
||||
"未检测到可用本地 sandbox 执行器。{}",
|
||||
workspace_sandbox_platform_hint()
|
||||
));
|
||||
}
|
||||
|
||||
let workspace_path = PathBuf::from(workspace_root);
|
||||
let mut read_only_paths = vec![
|
||||
PathBuf::from("/usr"),
|
||||
PathBuf::from("/bin"),
|
||||
PathBuf::from("/sbin"),
|
||||
PathBuf::from("/etc"),
|
||||
PathBuf::from("/System"),
|
||||
PathBuf::from("/Library"),
|
||||
workspace_path.clone(),
|
||||
];
|
||||
read_only_paths.sort();
|
||||
read_only_paths.dedup();
|
||||
|
||||
let mut writable_paths = vec![workspace_path.clone(), PathBuf::from("/tmp")];
|
||||
if cfg!(target_os = "macos") {
|
||||
writable_paths.push(PathBuf::from("/private/tmp"));
|
||||
}
|
||||
writable_paths.sort();
|
||||
writable_paths.dedup();
|
||||
|
||||
let base_sandbox_config = ProcessSandboxConfig {
|
||||
enabled: true,
|
||||
sandbox_type,
|
||||
allowed_paths: vec![workspace_path],
|
||||
denied_paths: Vec::new(),
|
||||
network_access: false,
|
||||
environment_variables: HashMap::new(),
|
||||
read_only_paths,
|
||||
writable_paths,
|
||||
allow_dev_access: false,
|
||||
allow_proc_access: false,
|
||||
allow_sys_access: false,
|
||||
env_whitelist: Vec::new(),
|
||||
tmpfs_size: "64M".to_string(),
|
||||
unshare_all: true,
|
||||
die_with_parent: true,
|
||||
new_session: true,
|
||||
docker: None,
|
||||
custom_args: Vec::new(),
|
||||
audit_logging: None,
|
||||
resource_limits: None,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
delegate: BashTool::new(),
|
||||
sandbox_type_name,
|
||||
base_sandbox_config,
|
||||
auto_approve_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn sandbox_type(&self) -> &str {
|
||||
&self.sandbox_type_name
|
||||
}
|
||||
|
||||
fn build_sandbox_config(
|
||||
&self,
|
||||
context: &ToolContext,
|
||||
timeout_secs: u64,
|
||||
) -> ProcessSandboxConfig {
|
||||
let mut config = self.base_sandbox_config.clone();
|
||||
|
||||
let mut environment_variables = HashMap::new();
|
||||
environment_variables.insert("ASTER_TERMINAL".to_string(), "1".to_string());
|
||||
for (key, value) in &context.environment {
|
||||
environment_variables.insert(key.clone(), value.clone());
|
||||
}
|
||||
if let Ok(path_env) = std::env::var("PATH") {
|
||||
environment_variables
|
||||
.entry("PATH".to_string())
|
||||
.or_insert(path_env);
|
||||
}
|
||||
|
||||
config.environment_variables = environment_variables;
|
||||
config.resource_limits = Some(ResourceLimits {
|
||||
max_memory: Some(1024 * 1024 * 1024),
|
||||
max_cpu: Some(70),
|
||||
max_processes: Some(32),
|
||||
max_file_size: Some(50 * 1024 * 1024),
|
||||
max_execution_time: Some(timeout_secs.saturating_mul(1000)),
|
||||
max_file_descriptors: Some(256),
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn quote_shell(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
fn build_shell_command(&self, command: &str, _context: &ToolContext) -> (String, Vec<String>) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return (
|
||||
"powershell".to_string(),
|
||||
vec![
|
||||
"-NoProfile".to_string(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Command".to_string(),
|
||||
command.to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let working_dir = _context.working_directory.to_string_lossy().to_string();
|
||||
let wrapped_command = format!("cd {} && {}", Self::quote_shell(&working_dir), command);
|
||||
("sh".to_string(), vec!["-lc".to_string(), wrapped_command])
|
||||
}
|
||||
}
|
||||
|
||||
fn format_output(stdout: &str, stderr: &str, exit_code: i32) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
if !stdout.is_empty() {
|
||||
output.push_str(stdout);
|
||||
}
|
||||
|
||||
if !stderr.is_empty() {
|
||||
if !output.is_empty() && !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
if !stdout.is_empty() {
|
||||
output.push_str("--- stderr ---\n");
|
||||
}
|
||||
output.push_str(stderr);
|
||||
}
|
||||
|
||||
if exit_code != 0 && output.is_empty() {
|
||||
output = format!("Command exited with code {exit_code}");
|
||||
}
|
||||
|
||||
if output.len() <= MAX_OUTPUT_LENGTH {
|
||||
return output;
|
||||
}
|
||||
|
||||
let bytes = output.as_bytes();
|
||||
let truncated = String::from_utf8_lossy(&bytes[..MAX_OUTPUT_LENGTH]).to_string();
|
||||
format!(
|
||||
"{}\n\n[output truncated: {} bytes total]",
|
||||
truncated,
|
||||
output.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_shell_command_params(params: &serde_json::Value) -> serde_json::Value {
|
||||
let mut normalized = params.clone();
|
||||
if let Some(object) = normalized.as_object_mut() {
|
||||
let has_command = object
|
||||
.get("command")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_command {
|
||||
if let Some(cmd_value) = object.get("cmd").cloned() {
|
||||
if cmd_value
|
||||
.as_str()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
object.insert("command".to_string(), cmd_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_workspace_tool_permission_behavior(
|
||||
permission: PermissionCheckResult,
|
||||
auto_approve_warnings: bool,
|
||||
) -> PermissionCheckResult {
|
||||
if permission.behavior != PermissionBehavior::Ask {
|
||||
return permission;
|
||||
}
|
||||
|
||||
let warning = permission
|
||||
.message
|
||||
.unwrap_or_else(|| "命令包含潜在风险操作".to_string());
|
||||
|
||||
if auto_approve_warnings {
|
||||
tracing::warn!("[AsterAgent] Auto 模式自动通过 bash 风险提示: {}", warning);
|
||||
return PermissionCheckResult {
|
||||
behavior: PermissionBehavior::Allow,
|
||||
message: None,
|
||||
updated_params: permission.updated_params,
|
||||
};
|
||||
}
|
||||
|
||||
PermissionCheckResult {
|
||||
behavior: PermissionBehavior::Deny,
|
||||
message: Some(format!(
|
||||
"{warning}。当前模式不支持交互确认,请切换到 Auto 模式或调整命令。"
|
||||
)),
|
||||
updated_params: permission.updated_params,
|
||||
}
|
||||
}
|
||||
|
||||
fn append_workspace_bash_summary(
|
||||
mut output: String,
|
||||
exit_code: i32,
|
||||
stdout_length: usize,
|
||||
stderr_length: usize,
|
||||
sandboxed: bool,
|
||||
sandbox_type: &str,
|
||||
) -> String {
|
||||
if !output.is_empty() && !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
let output_truncated = output.contains("[output truncated:");
|
||||
output.push_str("\n[Lime 执行摘要]\n");
|
||||
output.push_str(&format!("exit_code: {exit_code}\n"));
|
||||
output.push_str(&format!("stdout_length: {stdout_length}\n"));
|
||||
output.push_str(&format!("stderr_length: {stderr_length}\n"));
|
||||
output.push_str(&format!("sandboxed: {sandboxed}\n"));
|
||||
output.push_str(&format!("sandbox_type: {sandbox_type}\n"));
|
||||
output.push_str(&format!("output_truncated: {output_truncated}"));
|
||||
output
|
||||
}
|
||||
|
||||
fn output_contains_lime_metadata_block(output: &str) -> bool {
|
||||
output.contains(LIME_TOOL_METADATA_BEGIN) && output.contains(LIME_TOOL_METADATA_END)
|
||||
}
|
||||
|
||||
fn append_lime_tool_metadata_block(
|
||||
mut content: String,
|
||||
metadata: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> String {
|
||||
if output_contains_lime_metadata_block(&content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if !content.is_empty() && !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_else(|_| "{}".to_string());
|
||||
content.push_str(LIME_TOOL_METADATA_BEGIN);
|
||||
content.push('\n');
|
||||
content.push_str(&metadata_json);
|
||||
content.push('\n');
|
||||
content.push_str(LIME_TOOL_METADATA_END);
|
||||
content
|
||||
}
|
||||
|
||||
pub(crate) fn encode_tool_result_for_harness_observability(result: ToolResult) -> ToolResult {
|
||||
let mut metadata = result.metadata.clone();
|
||||
let base_content = if result.success {
|
||||
result.output.unwrap_or_default()
|
||||
} else {
|
||||
metadata
|
||||
.entry("reported_success".to_string())
|
||||
.or_insert_with(|| serde_json::json!(false));
|
||||
result
|
||||
.error
|
||||
.unwrap_or_else(|| "工具执行失败,但未返回错误详情".to_string())
|
||||
};
|
||||
|
||||
if result.success && metadata.is_empty() {
|
||||
return ToolResult::success(base_content);
|
||||
}
|
||||
|
||||
let encoded_output =
|
||||
if metadata.is_empty() || output_contains_lime_metadata_block(&base_content) {
|
||||
base_content
|
||||
} else {
|
||||
let metadata_object = metadata
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect::<serde_json::Map<String, serde_json::Value>>();
|
||||
append_lime_tool_metadata_block(base_content, &metadata_object)
|
||||
};
|
||||
|
||||
ToolResult::success(encoded_output).with_metadata_map(metadata)
|
||||
}
|
||||
|
||||
fn remap_virtual_memory_path_param(
|
||||
params: &mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
) -> Result<bool, ToolError> {
|
||||
let Some(raw_path) = params.get(key).and_then(|value| value.as_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let Some(mapped_path) =
|
||||
resolve_virtual_memory_path(raw_path).map_err(ToolError::invalid_params)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
params.insert(
|
||||
key.to_string(),
|
||||
serde_json::Value::String(mapped_path.to_string_lossy().to_string()),
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn remap_virtual_memory_glob_pattern(
|
||||
params: &mut serde_json::Map<String, serde_json::Value>,
|
||||
) -> Result<bool, ToolError> {
|
||||
let Some(pattern) = params.get("pattern").and_then(|value| value.as_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !is_virtual_memory_path(pattern) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let relative_pattern = virtual_memory_relative_path(pattern).unwrap_or_default();
|
||||
if relative_pattern.split('/').any(|segment| segment == "..") {
|
||||
return Err(ToolError::invalid_params(
|
||||
"glob.pattern 中的 `/memories/` 路径不允许包含 `..`".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let root_path = resolve_virtual_memory_path(DURABLE_MEMORY_VIRTUAL_ROOT)
|
||||
.map_err(ToolError::invalid_params)?
|
||||
.ok_or_else(|| ToolError::invalid_params("无法解析 durable memory 根目录".to_string()))?;
|
||||
|
||||
let normalized_pattern = relative_pattern.trim_start_matches('/');
|
||||
let normalized_pattern = if normalized_pattern.is_empty() {
|
||||
"**/*".to_string()
|
||||
} else {
|
||||
normalized_pattern.to_string()
|
||||
};
|
||||
|
||||
params.insert(
|
||||
"path".to_string(),
|
||||
serde_json::Value::String(root_path.to_string_lossy().to_string()),
|
||||
);
|
||||
params.insert(
|
||||
"pattern".to_string(),
|
||||
serde_json::Value::String(normalized_pattern),
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_params_for_durable_memory_support(
|
||||
tool_name: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, ToolError> {
|
||||
let Some(map) = params.as_object() else {
|
||||
return Ok(params.clone());
|
||||
};
|
||||
|
||||
let mut normalized = map.clone();
|
||||
let mut changed = false;
|
||||
|
||||
match tool_name {
|
||||
"read" | "write" | "edit" | "grep" => {
|
||||
changed |= remap_virtual_memory_path_param(&mut normalized, "path")?;
|
||||
}
|
||||
"glob" => {
|
||||
changed |= remap_virtual_memory_path_param(&mut normalized, "path")?;
|
||||
changed |= remap_virtual_memory_glob_pattern(&mut normalized)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if changed {
|
||||
Ok(serde_json::Value::Object(normalized))
|
||||
} else {
|
||||
Ok(params.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct DurableMemoryMappedTool {
|
||||
delegate: Box<dyn Tool>,
|
||||
}
|
||||
|
||||
impl DurableMemoryMappedTool {
|
||||
fn new(delegate: Box<dyn Tool>) -> Self {
|
||||
Self { delegate }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for DurableMemoryMappedTool {
|
||||
fn name(&self) -> &str {
|
||||
self.delegate.name()
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
self.delegate.description()
|
||||
}
|
||||
|
||||
fn dynamic_description(&self) -> Option<String> {
|
||||
self.delegate.dynamic_description()
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
self.delegate.input_schema()
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
self.delegate.options()
|
||||
}
|
||||
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
let normalized_params =
|
||||
match normalize_params_for_durable_memory_support(self.name(), params) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
return PermissionCheckResult::deny(format!(
|
||||
"durable memory 参数无效: {error}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut result = self
|
||||
.delegate
|
||||
.check_permissions(&normalized_params, context)
|
||||
.await;
|
||||
|
||||
if result.updated_params.is_none() && normalized_params != *params {
|
||||
result.updated_params = Some(normalized_params);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let normalized_params = normalize_params_for_durable_memory_support(self.name(), ¶ms)?;
|
||||
self.delegate.execute(normalized_params, context).await
|
||||
}
|
||||
}
|
||||
|
||||
struct HarnessObservedTool {
|
||||
delegate: Box<dyn Tool>,
|
||||
}
|
||||
|
||||
impl HarnessObservedTool {
|
||||
fn new(delegate: Box<dyn Tool>) -> Self {
|
||||
Self { delegate }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HarnessObservedTool {
|
||||
fn name(&self) -> &str {
|
||||
self.delegate.name()
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
self.delegate.description()
|
||||
}
|
||||
|
||||
fn dynamic_description(&self) -> Option<String> {
|
||||
self.delegate.dynamic_description()
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
self.delegate.input_schema()
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
self.delegate.options()
|
||||
}
|
||||
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
self.delegate.check_permissions(params, context).await
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
self.delegate
|
||||
.execute(params, context)
|
||||
.await
|
||||
.map(encode_tool_result_for_harness_observability)
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_registry_native_tools_for_harness_observability(registry: &mut aster::tools::ToolRegistry) {
|
||||
let tool_names = registry
|
||||
.native_tool_names()
|
||||
.into_iter()
|
||||
.map(|name| name.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for tool_name in tool_names {
|
||||
let Some(tool) = registry.unregister(&tool_name) else {
|
||||
continue;
|
||||
};
|
||||
registry.register(Box::new(HarnessObservedTool::new(tool)));
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_registry_native_tools_for_durable_memory_fs(registry: &mut aster::tools::ToolRegistry) {
|
||||
for tool_name in ["read", "write", "edit", "glob", "grep"] {
|
||||
let Some(tool) = registry.unregister(tool_name) else {
|
||||
continue;
|
||||
};
|
||||
registry.register(Box::new(DurableMemoryMappedTool::new(tool)));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WorkspaceSandboxedBashTool {
|
||||
fn name(&self) -> &str {
|
||||
self.delegate.name()
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
self.delegate.description()
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
self.delegate.input_schema()
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
self.delegate.options()
|
||||
}
|
||||
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
let normalized_params = normalize_shell_command_params(params);
|
||||
let permission = self
|
||||
.delegate
|
||||
.check_permissions(&normalized_params, context)
|
||||
.await;
|
||||
normalize_workspace_tool_permission_behavior(permission, self.auto_approve_warnings)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let normalized_params = normalize_shell_command_params(¶ms);
|
||||
|
||||
if context.is_cancelled() {
|
||||
return Err(ToolError::Cancelled);
|
||||
}
|
||||
|
||||
let permission = self.check_permissions(&normalized_params, context).await;
|
||||
match permission.behavior {
|
||||
PermissionBehavior::Allow => {}
|
||||
PermissionBehavior::Deny => {
|
||||
let message = permission
|
||||
.message
|
||||
.unwrap_or_else(|| "命令被安全策略拒绝".to_string());
|
||||
return Err(ToolError::permission_denied(message));
|
||||
}
|
||||
PermissionBehavior::Ask => {
|
||||
let message = permission
|
||||
.message
|
||||
.unwrap_or_else(|| "命令需要人工确认".to_string());
|
||||
return Err(ToolError::permission_denied(message));
|
||||
}
|
||||
}
|
||||
|
||||
let command = normalized_params
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::invalid_params("Missing required parameter: command"))?;
|
||||
|
||||
let background = normalized_params
|
||||
.get("background")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if background {
|
||||
return Err(ToolError::invalid_params(
|
||||
"本地 sandbox 模式不支持 background=true",
|
||||
));
|
||||
}
|
||||
|
||||
let timeout_secs = normalized_params
|
||||
.get("timeout")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_BASH_TIMEOUT_SECS)
|
||||
.min(MAX_BASH_TIMEOUT_SECS);
|
||||
|
||||
let sandbox_config = self.build_sandbox_config(context, timeout_secs);
|
||||
let (entry, args) = self.build_shell_command(command, context);
|
||||
|
||||
let execution = tokio::time::timeout(
|
||||
Duration::from_secs(timeout_secs),
|
||||
execute_in_sandbox(&entry, &args, &sandbox_config),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ToolError::timeout(Duration::from_secs(timeout_secs)))?
|
||||
.map_err(|e| ToolError::execution_failed(format!("sandbox 执行失败: {e}")))?;
|
||||
|
||||
let output = append_workspace_bash_summary(
|
||||
Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code),
|
||||
execution.exit_code,
|
||||
execution.stdout.len(),
|
||||
execution.stderr.len(),
|
||||
execution.sandboxed,
|
||||
&format!("{:?}", execution.sandbox_type),
|
||||
);
|
||||
if execution.exit_code == 0 {
|
||||
Ok(ToolResult::success(output)
|
||||
.with_metadata("exit_code", serde_json::json!(execution.exit_code))
|
||||
.with_metadata("stdout_length", serde_json::json!(execution.stdout.len()))
|
||||
.with_metadata("stderr_length", serde_json::json!(execution.stderr.len()))
|
||||
.with_metadata("sandboxed", serde_json::json!(execution.sandboxed))
|
||||
.with_metadata(
|
||||
"sandbox_type",
|
||||
serde_json::json!(format!("{:?}", execution.sandbox_type)),
|
||||
))
|
||||
} else {
|
||||
Ok(ToolResult::success(output)
|
||||
.with_metadata("exit_code", serde_json::json!(execution.exit_code))
|
||||
.with_metadata("stdout_length", serde_json::json!(execution.stdout.len()))
|
||||
.with_metadata("stderr_length", serde_json::json!(execution.stderr.len()))
|
||||
.with_metadata("sandboxed", serde_json::json!(execution.sandboxed))
|
||||
.with_metadata(
|
||||
"sandbox_type",
|
||||
serde_json::json!(format!("{:?}", execution.sandbox_type)),
|
||||
)
|
||||
.with_metadata("reported_success", serde_json::json!(false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一处理 Task 工具的 Ask 权限,避免缺少回调导致流程中断
|
||||
struct WorkspaceTaskTool {
|
||||
delegate: TaskTool,
|
||||
auto_approve_warnings: bool,
|
||||
}
|
||||
|
||||
impl WorkspaceTaskTool {
|
||||
fn new(auto_approve_warnings: bool, task_manager: Arc<TaskManager>) -> Self {
|
||||
Self {
|
||||
delegate: TaskTool::with_manager(task_manager),
|
||||
auto_approve_warnings,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WorkspaceTaskTool {
|
||||
fn name(&self) -> &str {
|
||||
self.delegate.name()
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
self.delegate.description()
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
self.delegate.input_schema()
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
self.delegate.options()
|
||||
}
|
||||
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
let normalized_params = normalize_shell_command_params(params);
|
||||
let permission = self
|
||||
.delegate
|
||||
.check_permissions(&normalized_params, context)
|
||||
.await;
|
||||
normalize_workspace_tool_permission_behavior(permission, self.auto_approve_warnings)
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let normalized_params = normalize_shell_command_params(¶ms);
|
||||
self.delegate.execute(normalized_params, context).await
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceTaskOutputTool {
|
||||
delegate: TaskOutputTool,
|
||||
task_manager: Arc<TaskManager>,
|
||||
}
|
||||
|
||||
impl WorkspaceTaskOutputTool {
|
||||
fn new(task_manager: Arc<TaskManager>) -> Self {
|
||||
Self {
|
||||
delegate: TaskOutputTool::with_manager(task_manager.clone()),
|
||||
task_manager,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WorkspaceTaskOutputTool {
|
||||
fn name(&self) -> &str {
|
||||
self.delegate.name()
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
self.delegate.description()
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
self.delegate.input_schema()
|
||||
}
|
||||
|
||||
fn options(&self) -> ToolOptions {
|
||||
self.delegate.options()
|
||||
}
|
||||
|
||||
async fn check_permissions(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> PermissionCheckResult {
|
||||
self.delegate.check_permissions(params, context).await
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let input = serde_json::from_value::<TaskOutputInput>(params.clone()).ok();
|
||||
let mut result = self.delegate.execute(params, context).await?;
|
||||
|
||||
let Some(task_id) = input.map(|value| value.task_id) else {
|
||||
return Ok(result);
|
||||
};
|
||||
|
||||
let Some(state) = self.task_manager.get_status(&task_id).await else {
|
||||
return Ok(result);
|
||||
};
|
||||
|
||||
result = result
|
||||
.with_metadata(
|
||||
"output_file",
|
||||
serde_json::json!(state.output_file.to_string_lossy().to_string()),
|
||||
)
|
||||
.with_metadata(
|
||||
"working_directory",
|
||||
serde_json::json!(state.working_directory.to_string_lossy().to_string()),
|
||||
)
|
||||
.with_metadata("session_id", serde_json::json!(state.session_id))
|
||||
.with_metadata("status", serde_json::json!(state.status.to_string()));
|
||||
|
||||
if let Some(exit_code) = state.exit_code {
|
||||
result = result.with_metadata("exit_code", serde_json::json!(exit_code));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn register_workspace_runtime_tools(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
task_manager: Arc<TaskManager>,
|
||||
auto_approve_warnings: bool,
|
||||
sandboxed_bash_tool: Option<WorkspaceSandboxedBashTool>,
|
||||
) {
|
||||
registry.register(Box::new(WorkspaceTaskTool::new(
|
||||
auto_approve_warnings,
|
||||
task_manager.clone(),
|
||||
)));
|
||||
registry.register(Box::new(WorkspaceTaskOutputTool::new(task_manager.clone())));
|
||||
registry.register(Box::new(KillShellTool::with_task_manager(task_manager)));
|
||||
|
||||
if let Some(workspace_bash_tool) = sandboxed_bash_tool {
|
||||
registry.register(Box::new(workspace_bash_tool));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wrap_registry_native_tools_for_workspace_runtime(
|
||||
registry: &mut aster::tools::ToolRegistry,
|
||||
) {
|
||||
wrap_registry_native_tools_for_durable_memory_fs(registry);
|
||||
wrap_registry_native_tools_for_harness_observability(registry);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::claw_solution_service::{
|
||||
ClawSolutionContext, ClawSolutionDetail, ClawSolutionPreparation, ClawSolutionReadinessResult,
|
||||
ClawSolutionService, ClawSolutionSummary,
|
||||
};
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn claw_solution_list(
|
||||
db: State<'_, DbConnection>,
|
||||
) -> Result<Vec<ClawSolutionSummary>, String> {
|
||||
ClawSolutionService::default().list(&db).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn claw_solution_detail(
|
||||
db: State<'_, DbConnection>,
|
||||
solution_id: String,
|
||||
) -> Result<ClawSolutionDetail, String> {
|
||||
ClawSolutionService::default()
|
||||
.detail(&db, &solution_id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn claw_solution_check_readiness(
|
||||
db: State<'_, DbConnection>,
|
||||
solution_id: String,
|
||||
_context: Option<ClawSolutionContext>,
|
||||
) -> Result<ClawSolutionReadinessResult, String> {
|
||||
ClawSolutionService::default()
|
||||
.check_readiness(&db, &solution_id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn claw_solution_prepare(
|
||||
db: State<'_, DbConnection>,
|
||||
solution_id: String,
|
||||
context: Option<ClawSolutionContext>,
|
||||
) -> Result<ClawSolutionPreparation, String> {
|
||||
ClawSolutionService::default()
|
||||
.prepare(&db, &solution_id, context)
|
||||
.await
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use crate::config::{
|
||||
use crate::models::app_type::AppType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_autostart::ManagerExt;
|
||||
|
||||
@@ -583,688 +582,3 @@ pub async fn open_auth_dir(path: String) -> Result<bool, String> {
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ============ Version Check Commands ============
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VersionCheckResult {
|
||||
pub current: String,
|
||||
pub latest: Option<String>,
|
||||
#[serde(rename = "hasUpdate")]
|
||||
pub has_update: bool,
|
||||
#[serde(rename = "downloadUrl")]
|
||||
pub download_url: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/lime/releases";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct UpdateCheckCache {
|
||||
latest: Option<String>,
|
||||
download_url: Option<String>,
|
||||
etag: Option<String>,
|
||||
last_checked_unix: u64,
|
||||
}
|
||||
|
||||
/// 检查应用更新
|
||||
///
|
||||
/// 从 GitHub Releases API 获取最新版本信息并与当前版本比较
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates() -> Result<VersionCheckResult, String> {
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const GITHUB_API_URL: &str = "https://api.github.com/repos/aiclientproxy/lime/releases/latest";
|
||||
const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60;
|
||||
|
||||
let now_unix = current_unix_timestamp();
|
||||
let cache_path = get_update_check_cache_path();
|
||||
let cached = load_update_check_cache(&cache_path);
|
||||
|
||||
if let Some(cache) = &cached {
|
||||
if is_update_cache_fresh(cache, now_unix, UPDATE_CHECK_CACHE_TTL_SECS) {
|
||||
return Ok(build_version_check_result(
|
||||
CURRENT_VERSION,
|
||||
cache.latest.clone(),
|
||||
cache.download_url.clone(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client
|
||||
.get(GITHUB_API_URL)
|
||||
.header("User-Agent", "Lime")
|
||||
.header("Accept", "application/vnd.github+json");
|
||||
|
||||
if let Some(cache) = &cached {
|
||||
if let Some(etag) = &cache.etag {
|
||||
request = request.header("If-None-Match", etag);
|
||||
}
|
||||
}
|
||||
|
||||
match request.send().await {
|
||||
Ok(response) => {
|
||||
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
|
||||
if let Some(cache) = cached {
|
||||
let refreshed_cache = UpdateCheckCache {
|
||||
last_checked_unix: now_unix,
|
||||
..cache.clone()
|
||||
};
|
||||
let _ = save_update_check_cache(&cache_path, &refreshed_cache);
|
||||
return Ok(build_version_check_result(
|
||||
CURRENT_VERSION,
|
||||
refreshed_cache.latest,
|
||||
refreshed_cache.download_url,
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if response.status().is_success() {
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(reqwest::header::ETAG)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match response.json::<serde_json::Value>().await {
|
||||
Ok(data) => {
|
||||
let latest_version = data["tag_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('v');
|
||||
|
||||
let download_url = data["html_url"].as_str().map(|s| s.to_string());
|
||||
|
||||
let new_cache = UpdateCheckCache {
|
||||
latest: Some(latest_version.to_string()),
|
||||
download_url: download_url.clone(),
|
||||
etag,
|
||||
last_checked_unix: now_unix,
|
||||
};
|
||||
let _ = save_update_check_cache(&cache_path, &new_cache);
|
||||
|
||||
Ok(build_version_check_result(
|
||||
CURRENT_VERSION,
|
||||
Some(latest_version.to_string()),
|
||||
download_url,
|
||||
None,
|
||||
))
|
||||
}
|
||||
Err(e) => Ok(build_version_from_cache_or_default(
|
||||
CURRENT_VERSION,
|
||||
cached.as_ref(),
|
||||
Some(format!("解析更新信息失败,已回退本地缓存: {e}")),
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
let error_message = match response.status() {
|
||||
reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::TOO_MANY_REQUESTS => {
|
||||
"GitHub API 限流,已回退本地缓存,请稍后重试".to_string()
|
||||
}
|
||||
status => format!("GitHub API 请求失败: {status},已回退本地缓存"),
|
||||
};
|
||||
|
||||
Ok(build_version_from_cache_or_default(
|
||||
CURRENT_VERSION,
|
||||
cached.as_ref(),
|
||||
Some(error_message),
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(build_version_from_cache_or_default(
|
||||
CURRENT_VERSION,
|
||||
cached.as_ref(),
|
||||
Some(format!("网络请求失败,已回退本地缓存: {e}")),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_unix_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn get_update_check_cache_path() -> PathBuf {
|
||||
let base_dir = dirs::cache_dir()
|
||||
.or_else(dirs::config_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
base_dir.join("lime").join("update-check-cache.json")
|
||||
}
|
||||
|
||||
fn is_update_cache_fresh(cache: &UpdateCheckCache, now_unix: u64, ttl_secs: u64) -> bool {
|
||||
if cache.latest.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
now_unix.saturating_sub(cache.last_checked_unix) < ttl_secs
|
||||
}
|
||||
|
||||
fn load_update_check_cache(path: &PathBuf) -> Option<UpdateCheckCache> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str::<UpdateCheckCache>(&content).ok()
|
||||
}
|
||||
|
||||
fn save_update_check_cache(path: &PathBuf, cache: &UpdateCheckCache) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(cache).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn build_version_check_result(
|
||||
current: &str,
|
||||
latest: Option<String>,
|
||||
download_url: Option<String>,
|
||||
error: Option<String>,
|
||||
) -> VersionCheckResult {
|
||||
let resolved_download_url = download_url.or_else(|| Some(FALLBACK_RELEASES_URL.to_string()));
|
||||
let has_update = latest
|
||||
.as_deref()
|
||||
.map(|latest_version| version_compare(current, latest_version))
|
||||
.unwrap_or(false);
|
||||
|
||||
VersionCheckResult {
|
||||
current: current.to_string(),
|
||||
latest,
|
||||
has_update,
|
||||
download_url: resolved_download_url,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_version_from_cache_or_default(
|
||||
current: &str,
|
||||
cache: Option<&UpdateCheckCache>,
|
||||
error: Option<String>,
|
||||
) -> VersionCheckResult {
|
||||
if let Some(cached) = cache {
|
||||
return build_version_check_result(
|
||||
current,
|
||||
cached.latest.clone(),
|
||||
cached.download_url.clone(),
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
build_version_check_result(current, None, None, error)
|
||||
}
|
||||
|
||||
/// 简单的版本比较函数
|
||||
/// 返回 true 如果 latest > current
|
||||
fn version_compare(current: &str, latest: &str) -> bool {
|
||||
// 移除 'v' 前缀
|
||||
let current = current.trim_start_matches('v');
|
||||
let latest = latest.trim_start_matches('v');
|
||||
|
||||
let current_parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
let latest_parts: Vec<u32> = latest.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||
|
||||
let max_len = current_parts.len().max(latest_parts.len());
|
||||
|
||||
for i in 0..max_len {
|
||||
let current_part = current_parts.get(i).unwrap_or(&0);
|
||||
let latest_part = latest_parts.get(i).unwrap_or(&0);
|
||||
|
||||
if latest_part > current_part {
|
||||
return true;
|
||||
} else if latest_part < current_part {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_compare() {
|
||||
// 测试版本比较逻辑
|
||||
assert!(version_compare("0.14.0", "0.14.1"));
|
||||
assert!(version_compare("0.14.0", "0.15.0"));
|
||||
assert!(version_compare("0.14.0", "1.0.0"));
|
||||
assert!(!version_compare("0.14.1", "0.14.0"));
|
||||
assert!(!version_compare("0.14.0", "0.14.0"));
|
||||
assert!(!version_compare("1.0.0", "0.14.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_platform_patterns() {
|
||||
let patterns = get_platform_patterns();
|
||||
|
||||
// 在支持的平台上应该返回非空的模式列表
|
||||
#[cfg(any(
|
||||
all(
|
||||
target_os = "windows",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
),
|
||||
all(
|
||||
target_os = "macos",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
),
|
||||
all(
|
||||
target_os = "linux",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
)
|
||||
))]
|
||||
{
|
||||
assert!(!patterns.is_empty());
|
||||
}
|
||||
|
||||
// 在不支持的平台上应该返回空列表
|
||||
#[cfg(not(any(
|
||||
all(
|
||||
target_os = "windows",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
),
|
||||
all(
|
||||
target_os = "macos",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
),
|
||||
all(
|
||||
target_os = "linux",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
)
|
||||
)))]
|
||||
{
|
||||
assert!(patterns.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_update_cache_fresh() {
|
||||
let cache = UpdateCheckCache {
|
||||
latest: Some("0.76.0".to_string()),
|
||||
download_url: Some("https://example.com".to_string()),
|
||||
etag: Some("etag".to_string()),
|
||||
last_checked_unix: 100,
|
||||
};
|
||||
|
||||
assert!(is_update_cache_fresh(&cache, 150, 60));
|
||||
assert!(!is_update_cache_fresh(&cache, 170, 60));
|
||||
|
||||
let cache_without_latest = UpdateCheckCache {
|
||||
latest: None,
|
||||
..cache
|
||||
};
|
||||
assert!(!is_update_cache_fresh(&cache_without_latest, 120, 60));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
#[serde(rename = "filePath")]
|
||||
pub file_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 下载更新安装包
|
||||
///
|
||||
/// 从 GitHub Releases 下载对应平台的安装包到下载目录
|
||||
#[tauri::command]
|
||||
pub async fn download_update(app_handle: AppHandle) -> Result<DownloadResult, String> {
|
||||
// 首先检查是否有更新
|
||||
let version_info = check_for_updates().await?;
|
||||
|
||||
if !version_info.has_update {
|
||||
return Ok(DownloadResult {
|
||||
success: false,
|
||||
message: "当前已是最新版本".to_string(),
|
||||
file_path: None,
|
||||
});
|
||||
}
|
||||
|
||||
let latest_version = version_info.latest.ok_or("无法获取最新版本信息")?;
|
||||
|
||||
// 从 GitHub API 获取实际的文件列表并匹配平台
|
||||
let (filename, download_url) = get_platform_download_from_github(&latest_version).await?;
|
||||
|
||||
// 获取下载目录
|
||||
let download_dir = get_download_directory(&app_handle)?;
|
||||
let file_path = download_dir.join(&filename);
|
||||
|
||||
// 如果文件已存在,先删除
|
||||
if file_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&file_path) {
|
||||
tracing::warn!("删除旧文件失败: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
match client
|
||||
.get(&download_url)
|
||||
.header("User-Agent", "Lime")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if !response.status().is_success() {
|
||||
return Ok(DownloadResult {
|
||||
success: false,
|
||||
message: format!("下载失败: HTTP {}", response.status()),
|
||||
file_path: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取文件内容
|
||||
match response.bytes().await {
|
||||
Ok(bytes) => {
|
||||
// 写入文件
|
||||
match std::fs::write(&file_path, bytes) {
|
||||
Ok(_) => {
|
||||
tracing::info!("安装包下载成功: {:?}", file_path);
|
||||
|
||||
// 尝试直接运行安装程序
|
||||
match run_installer(&file_path) {
|
||||
Ok(_) => {
|
||||
tracing::info!("已启动安装程序,准备退出当前应用");
|
||||
|
||||
// 延迟退出,给安装程序时间启动
|
||||
tokio::spawn(async {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2))
|
||||
.await;
|
||||
tracing::info!("自动退出应用以便安装程序运行");
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("启动安装程序失败: {},尝试打开文件位置", e);
|
||||
// 如果无法运行安装程序,则打开文件所在目录
|
||||
if let Err(open_err) = open_file_location(&file_path) {
|
||||
tracing::warn!("打开文件所在目录也失败: {}", open_err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DownloadResult {
|
||||
success: true,
|
||||
message: format!("下载完成: {filename}"),
|
||||
file_path: Some(file_path.to_string_lossy().to_string()),
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(DownloadResult {
|
||||
success: false,
|
||||
message: format!("保存文件失败: {e}"),
|
||||
file_path: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(DownloadResult {
|
||||
success: false,
|
||||
message: format!("读取下载内容失败: {e}"),
|
||||
file_path: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(DownloadResult {
|
||||
success: false,
|
||||
message: format!("网络请求失败: {e}"),
|
||||
file_path: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 GitHub API 获取实际的文件列表并匹配平台
|
||||
async fn get_platform_download_from_github(version: &str) -> Result<(String, String), String> {
|
||||
let api_url =
|
||||
format!("https://api.github.com/repos/aiclientproxy/lime/releases/tags/v{version}");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&api_url)
|
||||
.header("User-Agent", "Lime")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求 GitHub API 失败: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("GitHub API 请求失败: {}", response.status()));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析 GitHub API 响应失败: {e}"))?;
|
||||
|
||||
let assets = data["assets"]
|
||||
.as_array()
|
||||
.ok_or("GitHub API 响应中没有找到 assets")?;
|
||||
|
||||
// 根据当前平台匹配文件
|
||||
let platform_patterns = get_platform_patterns();
|
||||
|
||||
for asset in assets {
|
||||
let name = asset["name"].as_str().unwrap_or("");
|
||||
let download_url = asset["browser_download_url"].as_str().unwrap_or("");
|
||||
|
||||
for pattern in &platform_patterns {
|
||||
if name.contains(pattern) {
|
||||
return Ok((name.to_string(), download_url.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("未找到适合当前平台的安装包".to_string())
|
||||
}
|
||||
|
||||
/// 获取当前平台的文件名匹配模式
|
||||
fn get_platform_patterns() -> Vec<&'static str> {
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
vec![
|
||||
"x64-online-setup.exe",
|
||||
"x64-setup.exe",
|
||||
"x64-offline-setup.exe",
|
||||
"x64_en-US.msi",
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
{
|
||||
vec![
|
||||
"arm64-online-setup.exe",
|
||||
"arm64-setup.exe",
|
||||
"arm64-offline-setup.exe",
|
||||
"arm64_en-US.msi",
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
vec!["x64.dmg"]
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
vec!["aarch64.dmg"]
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
vec!["amd64.deb", "amd64.AppImage"]
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
|
||||
{
|
||||
vec!["arm64.deb", "arm64.AppImage"]
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
all(
|
||||
target_os = "windows",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
),
|
||||
all(
|
||||
target_os = "macos",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
),
|
||||
all(
|
||||
target_os = "linux",
|
||||
any(target_arch = "x86_64", target_arch = "aarch64")
|
||||
)
|
||||
)))]
|
||||
{
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取下载目录
|
||||
fn get_download_directory(app_handle: &AppHandle) -> Result<PathBuf, String> {
|
||||
// 优先使用系统下载目录
|
||||
if let Some(download_dir) = dirs::download_dir() {
|
||||
return Ok(download_dir);
|
||||
}
|
||||
|
||||
// 回退到应用数据目录
|
||||
let _ = app_handle;
|
||||
let app_data_dir = lime_core::app_paths::preferred_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
|
||||
let download_dir = app_data_dir.join("downloads");
|
||||
|
||||
// 确保目录存在
|
||||
std::fs::create_dir_all(&download_dir).map_err(|e| format!("创建下载目录失败: {e}"))?;
|
||||
|
||||
Ok(download_dir)
|
||||
}
|
||||
|
||||
/// 运行安装程序
|
||||
fn run_installer(file_path: &PathBuf) -> Result<(), String> {
|
||||
let extension = file_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match extension.to_lowercase().as_str() {
|
||||
"exe" | "msi" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
tracing::info!("Windows: 启动安装程序: {:?}", file_path);
|
||||
std::process::Command::new(file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("启动 Windows 安装程序失败: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
return Err("Windows 安装程序只能在 Windows 系统上运行".to_string());
|
||||
}
|
||||
}
|
||||
"dmg" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
tracing::info!("macOS: 打开 DMG 文件: {:?}", file_path);
|
||||
std::process::Command::new("open")
|
||||
.arg(file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("打开 macOS DMG 文件失败: {e}"))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
return Err("DMG 文件只能在 macOS 系统上打开".to_string());
|
||||
}
|
||||
}
|
||||
"deb" => {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
tracing::info!("Linux: 尝试安装 DEB 包: {:?}", file_path);
|
||||
// 尝试使用系统默认的包管理器打开
|
||||
let result = std::process::Command::new("xdg-open")
|
||||
.arg(&file_path)
|
||||
.spawn();
|
||||
|
||||
if result.is_err() {
|
||||
// 如果 xdg-open 失败,尝试使用 dpkg
|
||||
tracing::info!("xdg-open 失败,尝试使用 gdebi 或提示用户手动安装");
|
||||
return Err("请手动安装 DEB 包,或使用: sudo dpkg -i filename.deb".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
return Err("DEB 包只能在 Linux 系统上安装".to_string());
|
||||
}
|
||||
}
|
||||
"appimage" => {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
tracing::info!("Linux: 设置 AppImage 可执行权限并运行: {:?}", file_path);
|
||||
// 设置可执行权限
|
||||
std::process::Command::new("chmod")
|
||||
.args(&["+x", &file_path.to_string_lossy()])
|
||||
.output()
|
||||
.map_err(|e| format!("设置 AppImage 可执行权限失败: {}", e))?;
|
||||
|
||||
// 运行 AppImage
|
||||
std::process::Command::new(&file_path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("运行 AppImage 失败: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
return Err("AppImage 只能在 Linux 系统上运行".to_string());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("不支持的文件类型: {extension}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打开文件所在位置
|
||||
fn open_file_location(file_path: &PathBuf) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
tracing::info!("Windows: 使用 explorer 打开文件位置: {:?}", file_path);
|
||||
std::process::Command::new("explorer")
|
||||
.args(["/select,", &file_path.to_string_lossy()])
|
||||
.creation_flags(0x08000000) // CREATE_NO_WINDOW
|
||||
.spawn()
|
||||
.map_err(|e| format!("Windows explorer 启动失败: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
tracing::info!("macOS: 使用 open -R 打开文件位置: {:?}", file_path);
|
||||
std::process::Command::new("open")
|
||||
.args(["-R", &file_path.to_string_lossy()])
|
||||
.spawn()
|
||||
.map_err(|e| format!("macOS open 命令失败: {e}"))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tracing::info!("Linux: 使用 xdg-open 打开目录: {:?}", parent);
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(parent)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Linux xdg-open 命令失败: {}", e))?;
|
||||
} else {
|
||||
return Err("无法获取文件的父目录".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
return Err("不支持的操作系统".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ use crate::services::chat_history_service::{load_memory_source_candidates, Memor
|
||||
use crate::services::memory_source_resolver_service::{
|
||||
resolve_effective_sources, EffectiveMemorySourcesResponse,
|
||||
};
|
||||
use crate::services::runtime_agents_template_service::{
|
||||
ensure_workspace_local_agents_gitignore, scaffold_runtime_agents_template,
|
||||
RuntimeAgentsTemplateScaffoldResult, RuntimeAgentsTemplateTarget,
|
||||
WorkspaceGitignoreEnsureResult,
|
||||
};
|
||||
use chrono::{Local, NaiveDateTime, TimeZone};
|
||||
use lime_core::app_paths;
|
||||
use lime_services::context_memory_service::{MemoryEntry, MemoryFileType};
|
||||
@@ -412,6 +417,36 @@ pub async fn memory_update_auto_note(
|
||||
)
|
||||
}
|
||||
|
||||
/// 显式生成运行时 AGENTS 模板
|
||||
#[tauri::command]
|
||||
pub async fn memory_scaffold_runtime_agents_template(
|
||||
target: RuntimeAgentsTemplateTarget,
|
||||
working_dir: Option<String>,
|
||||
overwrite: Option<bool>,
|
||||
) -> Result<RuntimeAgentsTemplateScaffoldResult, String> {
|
||||
let resolved_working_dir = match target {
|
||||
RuntimeAgentsTemplateTarget::Global => None,
|
||||
RuntimeAgentsTemplateTarget::Workspace | RuntimeAgentsTemplateTarget::WorkspaceLocal => {
|
||||
Some(resolve_working_dir(working_dir)?)
|
||||
}
|
||||
};
|
||||
|
||||
scaffold_runtime_agents_template(
|
||||
target,
|
||||
resolved_working_dir.as_deref(),
|
||||
overwrite.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
/// 确保 Workspace `.gitignore` 忽略 `.lime/AGENTS.local.md`
|
||||
#[tauri::command]
|
||||
pub async fn memory_ensure_workspace_local_agents_gitignore(
|
||||
working_dir: Option<String>,
|
||||
) -> Result<WorkspaceGitignoreEnsureResult, String> {
|
||||
let resolved_working_dir = resolve_working_dir(working_dir)?;
|
||||
ensure_workspace_local_agents_gitignore(&resolved_working_dir)
|
||||
}
|
||||
|
||||
fn resolve_memory_dir() -> PathBuf {
|
||||
app_paths::best_effort_runtime_subdir("memory")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod browser_environment_cmd;
|
||||
pub mod browser_profile_cmd;
|
||||
pub mod browser_runtime_cmd;
|
||||
pub mod channels_cmd;
|
||||
pub mod claw_solution_cmd;
|
||||
pub mod config_cmd;
|
||||
pub mod connect_cmd;
|
||||
pub mod connection_cmd;
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::models::project_model::{
|
||||
CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate, UpdateBrandExtensionRequest,
|
||||
};
|
||||
use crate::services::memory_profile_prompt_service::{build_memory_prompt, MemoryPromptContext};
|
||||
use lime_agent::merge_system_prompt_with_runtime_agents;
|
||||
use lime_services::persona_service::PersonaService;
|
||||
|
||||
// ============================================================================
|
||||
@@ -361,10 +362,19 @@ pub async fn generate_persona(
|
||||
let mut session_config_builder =
|
||||
crate::agent::aster_state::SessionConfigBuilder::new(&session_id)
|
||||
.include_context_trace(true);
|
||||
if let Some(memory_prompt) =
|
||||
let base_runtime_prompt = merge_system_prompt_with_runtime_agents(None, None);
|
||||
let merged_prompt = if let Some(memory_prompt) =
|
||||
build_memory_prompt(&config_manager.config(), MemoryPromptContext::default())
|
||||
{
|
||||
session_config_builder = session_config_builder.system_prompt(memory_prompt);
|
||||
match base_runtime_prompt {
|
||||
Some(base) => Some(format!("{base}\n\n{memory_prompt}")),
|
||||
None => Some(memory_prompt),
|
||||
}
|
||||
} else {
|
||||
base_runtime_prompt
|
||||
};
|
||||
if let Some(prompt) = merged_prompt {
|
||||
session_config_builder = session_config_builder.system_prompt(prompt);
|
||||
}
|
||||
let session_config = session_config_builder.build();
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ use crate::services::web_search_runtime_service::apply_web_search_runtime_env;
|
||||
use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate;
|
||||
use crate::workspace::WorkspaceManager;
|
||||
use lime_agent::{
|
||||
resolve_request_tool_policy_with_mode, stream_reply_with_policy, RequestToolPolicyMode,
|
||||
SessionConfigBuilder,
|
||||
merge_system_prompt_with_runtime_agents, resolve_request_tool_policy_with_mode,
|
||||
stream_reply_with_policy, RequestToolPolicyMode, SessionConfigBuilder,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
@@ -389,7 +389,10 @@ pub async fn aster_agent_theme_context_search(
|
||||
let system_prompt = lime_agent::merge_system_prompt_with_request_tool_policy(
|
||||
merge_system_prompt_with_web_search(
|
||||
merge_system_prompt_with_memory_context(
|
||||
project_prompt,
|
||||
merge_system_prompt_with_runtime_agents(
|
||||
project_prompt,
|
||||
Some(Path::new(&workspace_root)),
|
||||
),
|
||||
&runtime_config,
|
||||
MemoryPromptContext::with_working_dir(Path::new(&workspace_root)),
|
||||
),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! 更新检查命令模块
|
||||
//!
|
||||
//! 提供自动更新检查相关的 Tauri 命令
|
||||
//!
|
||||
//! input: 前端调用请求
|
||||
//! output: 更新信息、配置操作结果
|
||||
//! pos: commands 层,被前端调用
|
||||
//! 提供自动更新检查相关的 Tauri 命令。
|
||||
//! 检查逻辑走静态 `latest.json` 清单,安装逻辑走 Tauri updater。
|
||||
|
||||
use crate::app::AppState;
|
||||
use crate::config;
|
||||
@@ -13,11 +10,22 @@ use lime_services::update_check_service::{
|
||||
UpdateCheckService, UpdateCheckServiceState, UpdateInfo,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tokio::sync::RwLock;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
const DAY_SECONDS: u64 = 24 * 3600;
|
||||
const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60;
|
||||
const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/lime/releases";
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://github.com/aiclientproxy/lime/releases/latest/download/latest.json";
|
||||
|
||||
/// 编译期注入 updater 公钥;开发环境可为空,此时仅保留手动下载兜底。
|
||||
const COMPILED_UPDATER_PUBLIC_KEY: Option<&str> = option_env!("LIME_UPDATER_PUBLIC_KEY");
|
||||
/// 编译期注入 updater manifest 地址;未配置时使用 GitHub Releases latest.json。
|
||||
const COMPILED_UPDATER_ENDPOINT: Option<&str> = option_env!("LIME_UPDATER_ENDPOINT");
|
||||
|
||||
/// 更新检查配置(前端可见)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -44,6 +52,55 @@ pub struct UpdateNotificationMetrics {
|
||||
pub dismiss_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VersionCheckResult {
|
||||
pub current: String,
|
||||
pub latest: Option<String>,
|
||||
#[serde(rename = "hasUpdate")]
|
||||
pub has_update: bool,
|
||||
#[serde(rename = "downloadUrl")]
|
||||
pub download_url: Option<String>,
|
||||
#[serde(rename = "releaseNotes")]
|
||||
pub release_notes: Option<String>,
|
||||
#[serde(rename = "pubDate")]
|
||||
pub pub_date: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
#[serde(rename = "filePath")]
|
||||
pub file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
struct UpdateCheckCache {
|
||||
latest: Option<String>,
|
||||
download_url: Option<String>,
|
||||
release_notes: Option<String>,
|
||||
pub_date: Option<String>,
|
||||
last_checked_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StaticUpdateManifest {
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub_date: Option<String>,
|
||||
platforms: HashMap<String, StaticUpdatePlatform>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StaticUpdatePlatform {
|
||||
url: String,
|
||||
#[allow(dead_code)]
|
||||
signature: Option<String>,
|
||||
}
|
||||
|
||||
fn rate_percent(numerator: u64, denominator: u64) -> f64 {
|
||||
if denominator == 0 {
|
||||
return 0.0;
|
||||
@@ -52,13 +109,346 @@ fn rate_percent(numerator: u64, denominator: u64) -> f64 {
|
||||
(rate * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
/// 手动检查更新
|
||||
fn current_unix_timestamp() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn updater_manifest_url() -> &'static str {
|
||||
COMPILED_UPDATER_ENDPOINT
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_UPDATE_MANIFEST_URL)
|
||||
}
|
||||
|
||||
fn updater_public_key() -> Option<&'static str> {
|
||||
COMPILED_UPDATER_PUBLIC_KEY
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn release_tag_url(version: &str) -> String {
|
||||
format!(
|
||||
"https://github.com/aiclientproxy/lime/releases/tag/v{}",
|
||||
version.trim_start_matches('v')
|
||||
)
|
||||
}
|
||||
|
||||
fn current_platform_key() -> Option<&'static str> {
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
return Some("windows-x86_64");
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
{
|
||||
return Some("windows-aarch64");
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
return Some("darwin-x86_64");
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
return Some("darwin-aarch64");
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
None
|
||||
}
|
||||
|
||||
fn get_update_check_cache_path() -> PathBuf {
|
||||
let base_dir = dirs::cache_dir()
|
||||
.or_else(dirs::config_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
base_dir.join("lime").join("update-check-cache.json")
|
||||
}
|
||||
|
||||
fn is_update_cache_fresh(cache: &UpdateCheckCache, now_unix: u64, ttl_secs: u64) -> bool {
|
||||
if cache.latest.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
now_unix.saturating_sub(cache.last_checked_unix) < ttl_secs
|
||||
}
|
||||
|
||||
fn load_update_check_cache(path: &PathBuf) -> Option<UpdateCheckCache> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str::<UpdateCheckCache>(&content).ok()
|
||||
}
|
||||
|
||||
fn save_update_check_cache(path: &PathBuf, cache: &UpdateCheckCache) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(cache).map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn build_update_info(
|
||||
latest: Option<String>,
|
||||
release_notes: Option<String>,
|
||||
pub_date: Option<String>,
|
||||
error: Option<String>,
|
||||
) -> UpdateInfo {
|
||||
let current_version = UpdateCheckService::current_version().to_string();
|
||||
let latest_version = latest
|
||||
.as_deref()
|
||||
.map(|value| value.trim_start_matches('v').to_string());
|
||||
let download_url = latest_version
|
||||
.as_deref()
|
||||
.map(release_tag_url)
|
||||
.or_else(|| Some(FALLBACK_RELEASES_URL.to_string()));
|
||||
let has_update = latest_version
|
||||
.as_deref()
|
||||
.map(|latest_version| UpdateCheckService::version_compare(¤t_version, latest_version))
|
||||
.unwrap_or(false);
|
||||
|
||||
UpdateInfo {
|
||||
current_version,
|
||||
latest_version,
|
||||
has_update,
|
||||
download_url: download_url.clone(),
|
||||
release_notes_url: download_url,
|
||||
release_notes,
|
||||
pub_date,
|
||||
checked_at: current_unix_timestamp(),
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_update_info_from_cache_or_default(
|
||||
cache: Option<&UpdateCheckCache>,
|
||||
error: Option<String>,
|
||||
) -> UpdateInfo {
|
||||
if let Some(cached) = cache {
|
||||
let mut info = build_update_info(
|
||||
cached.latest.clone(),
|
||||
cached.release_notes.clone(),
|
||||
cached.pub_date.clone(),
|
||||
error,
|
||||
);
|
||||
if cached.download_url.is_some() {
|
||||
info.download_url = cached.download_url.clone();
|
||||
info.release_notes_url = cached.download_url.clone();
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
build_update_info(None, None, None, error)
|
||||
}
|
||||
|
||||
fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult {
|
||||
VersionCheckResult {
|
||||
current: info.current_version,
|
||||
latest: info.latest_version,
|
||||
has_update: info.has_update,
|
||||
download_url: info.download_url,
|
||||
release_notes: info.release_notes,
|
||||
pub_date: info.pub_date,
|
||||
error: info.error,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_to_cache(manifest: &StaticUpdateManifest, checked_at: u64) -> UpdateCheckCache {
|
||||
UpdateCheckCache {
|
||||
latest: Some(manifest.version.trim_start_matches('v').to_string()),
|
||||
download_url: Some(release_tag_url(&manifest.version)),
|
||||
release_notes: manifest.notes.clone(),
|
||||
pub_date: manifest.pub_date.clone(),
|
||||
last_checked_unix: checked_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_update_info_from_manifest(manifest: StaticUpdateManifest) -> UpdateInfo {
|
||||
let latest_version = manifest.version.trim_start_matches('v').to_string();
|
||||
let platform_error = match current_platform_key() {
|
||||
Some(platform_key)
|
||||
if manifest
|
||||
.platforms
|
||||
.get(platform_key)
|
||||
.is_some_and(|platform| !platform.url.trim().is_empty()) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
Some(platform_key) if manifest.platforms.contains_key(platform_key) => Some(format!(
|
||||
"已检测到新版本,但当前平台 {} 的安装包地址为空,请前往发布页手动下载",
|
||||
platform_key
|
||||
)),
|
||||
Some(platform_key) => Some(format!(
|
||||
"已检测到新版本,但当前平台 {} 暂无安装包,请前往发布页手动下载",
|
||||
platform_key
|
||||
)),
|
||||
None => Some("当前平台暂不支持应用内升级,请前往发布页手动下载".to_string()),
|
||||
};
|
||||
|
||||
build_update_info(
|
||||
Some(latest_version),
|
||||
manifest.notes,
|
||||
manifest.pub_date,
|
||||
platform_error,
|
||||
)
|
||||
}
|
||||
|
||||
async fn fetch_update_info() -> UpdateInfo {
|
||||
let now_unix = current_unix_timestamp();
|
||||
let cache_path = get_update_check_cache_path();
|
||||
let cached = load_update_check_cache(&cache_path);
|
||||
|
||||
if let Some(cache) = &cached {
|
||||
if is_update_cache_fresh(cache, now_unix, UPDATE_CHECK_CACHE_TTL_SECS) {
|
||||
return build_update_info_from_cache_or_default(cached.as_ref(), None);
|
||||
}
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
{
|
||||
Ok(client) => client,
|
||||
Err(error) => {
|
||||
return build_update_info_from_cache_or_default(
|
||||
cached.as_ref(),
|
||||
Some(format!("创建更新检查客户端失败,已回退本地缓存: {error}")),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
match client
|
||||
.get(updater_manifest_url())
|
||||
.header("User-Agent", "Lime")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if !response.status().is_success() {
|
||||
return build_update_info_from_cache_or_default(
|
||||
cached.as_ref(),
|
||||
Some(format!(
|
||||
"更新清单请求失败(HTTP {}),已回退本地缓存",
|
||||
response.status()
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
match response.json::<StaticUpdateManifest>().await {
|
||||
Ok(manifest) => {
|
||||
let cache = manifest_to_cache(&manifest, now_unix);
|
||||
let _ = save_update_check_cache(&cache_path, &cache);
|
||||
build_update_info_from_manifest(manifest)
|
||||
}
|
||||
Err(error) => build_update_info_from_cache_or_default(
|
||||
cached.as_ref(),
|
||||
Some(format!("解析更新清单失败,已回退本地缓存: {error}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(error) => build_update_info_from_cache_or_default(
|
||||
cached.as_ref(),
|
||||
Some(format!("请求更新清单失败,已回退本地缓存: {error}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_update_check(update_service: &UpdateCheckServiceState) -> UpdateInfo {
|
||||
{
|
||||
let service = update_service.0.read().await;
|
||||
service.begin_check().await;
|
||||
}
|
||||
|
||||
let result = fetch_update_info().await;
|
||||
|
||||
let service = update_service.0.read().await;
|
||||
service.finish_check(result).await
|
||||
}
|
||||
|
||||
async fn install_update_via_updater(app_handle: &AppHandle) -> Result<(), String> {
|
||||
let public_key = updater_public_key()
|
||||
.ok_or_else(|| "当前构建未内置更新签名公钥,请前往网页下载最新版".to_string())?;
|
||||
let manifest_url = url::Url::parse(updater_manifest_url())
|
||||
.map_err(|error| format!("更新清单地址无效: {error}"))?;
|
||||
|
||||
let updater = app_handle
|
||||
.updater_builder()
|
||||
.pubkey(public_key)
|
||||
.endpoints(vec![manifest_url])
|
||||
.map_err(|error| format!("初始化更新源失败: {error}"))?
|
||||
.build()
|
||||
.map_err(|error| format!("创建 updater 失败: {error}"))?;
|
||||
|
||||
let update = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|error| format!("检查更新安装包失败: {error}"))?
|
||||
.ok_or_else(|| "当前已是最新版本".to_string())?;
|
||||
|
||||
update
|
||||
.download_and_install(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|error| format!("安装更新失败: {error}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 手动检查更新,返回完整检查结果
|
||||
#[tauri::command]
|
||||
pub async fn check_update(
|
||||
update_service: State<'_, UpdateCheckServiceState>,
|
||||
) -> Result<UpdateInfo, String> {
|
||||
let service = update_service.0.read().await;
|
||||
Ok(service.check_for_updates().await)
|
||||
Ok(perform_update_check(update_service.inner()).await)
|
||||
}
|
||||
|
||||
/// 手动检查更新,返回前端兼容结构
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(
|
||||
update_service: State<'_, UpdateCheckServiceState>,
|
||||
) -> Result<VersionCheckResult, String> {
|
||||
let info = perform_update_check(update_service.inner()).await;
|
||||
Ok(build_version_check_result(info))
|
||||
}
|
||||
|
||||
/// 下载并安装更新
|
||||
#[tauri::command]
|
||||
pub async fn download_update(
|
||||
app_handle: AppHandle,
|
||||
update_service: State<'_, UpdateCheckServiceState>,
|
||||
) -> Result<DownloadResult, String> {
|
||||
let update_info = perform_update_check(update_service.inner()).await;
|
||||
|
||||
if !update_info.has_update {
|
||||
return Ok(DownloadResult {
|
||||
success: false,
|
||||
message: "当前已是最新版本".to_string(),
|
||||
file_path: None,
|
||||
});
|
||||
}
|
||||
|
||||
match install_update_via_updater(&app_handle).await {
|
||||
Ok(()) => {
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
app_handle_clone.restart();
|
||||
});
|
||||
|
||||
Ok(DownloadResult {
|
||||
success: true,
|
||||
message: "更新已安装,应用即将重启完成升级".to_string(),
|
||||
file_path: None,
|
||||
})
|
||||
}
|
||||
Err(error) => Ok(DownloadResult {
|
||||
success: false,
|
||||
message: format!("{error}。请前往发布页手动下载最新版"),
|
||||
file_path: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取更新检查配置
|
||||
@@ -169,7 +559,6 @@ pub async fn skip_update_version(
|
||||
|
||||
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?;
|
||||
|
||||
// 关闭更新窗口
|
||||
let _ = update_window::close_update_window(&app_handle);
|
||||
|
||||
Ok(())
|
||||
@@ -183,10 +572,7 @@ pub async fn remind_update_later(
|
||||
hours: Option<u32>,
|
||||
) -> Result<u64, String> {
|
||||
let remind_hours = hours.unwrap_or(24).clamp(1, 24 * 30);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let now = current_unix_timestamp();
|
||||
let remind_until = now + (remind_hours as u64 * 3600);
|
||||
|
||||
let mut state = app_state.write().await;
|
||||
@@ -199,7 +585,6 @@ pub async fn remind_update_later(
|
||||
|
||||
config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?;
|
||||
|
||||
// 关闭更新窗口
|
||||
let _ = update_window::close_update_window(&app_handle);
|
||||
|
||||
Ok(remind_until)
|
||||
@@ -212,10 +597,7 @@ pub async fn dismiss_update_notification(
|
||||
app_state: State<'_, AppState>,
|
||||
version: Option<String>,
|
||||
) -> Result<u64, String> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let now = current_unix_timestamp();
|
||||
|
||||
let mut state = app_state.write().await;
|
||||
let update_config = &mut state.config.experimental.update_check;
|
||||
@@ -251,19 +633,16 @@ pub fn close_update_window(app_handle: AppHandle) -> Result<(), String> {
|
||||
/// 测试更新提醒窗口(仅开发环境使用)
|
||||
#[tauri::command]
|
||||
pub fn test_update_window(app_handle: AppHandle) -> Result<(), String> {
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
let current_version = UpdateCheckService::current_version();
|
||||
let test_info = UpdateInfo {
|
||||
current_version: current_version.to_string(),
|
||||
latest_version: Some("0.99.0".to_string()),
|
||||
has_update: true,
|
||||
download_url: Some(
|
||||
"https://github.com/aiclientproxy/lime/releases/tag/v0.99.0".to_string(),
|
||||
),
|
||||
release_notes_url: None,
|
||||
checked_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
download_url: Some(release_tag_url("0.99.0")),
|
||||
release_notes_url: Some(release_tag_url("0.99.0")),
|
||||
release_notes: Some("这是用于开发环境的模拟更新提醒。".to_string()),
|
||||
pub_date: Some("2026-03-21T00:00:00Z".to_string()),
|
||||
checked_at: current_unix_timestamp(),
|
||||
error: None,
|
||||
};
|
||||
|
||||
@@ -274,10 +653,7 @@ pub fn test_update_window(app_handle: AppHandle) -> Result<(), String> {
|
||||
/// 更新上次检查时间
|
||||
#[tauri::command]
|
||||
pub async fn update_last_check_timestamp(app_state: State<'_, AppState>) -> Result<u64, String> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let now = current_unix_timestamp();
|
||||
|
||||
let mut state = app_state.write().await;
|
||||
state.config.experimental.update_check.last_check_timestamp = now;
|
||||
@@ -289,19 +665,17 @@ pub async fn update_last_check_timestamp(app_state: State<'_, AppState>) -> Resu
|
||||
|
||||
/// 启动后台更新检查任务
|
||||
///
|
||||
/// 在应用启动时调用,根据配置定期检查更新
|
||||
/// 在应用启动时调用,根据配置定期检查更新。
|
||||
pub async fn start_background_update_check(
|
||||
app_handle: tauri::AppHandle,
|
||||
update_service: Arc<RwLock<UpdateCheckService>>,
|
||||
update_service: UpdateCheckServiceState,
|
||||
) {
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// 延迟 30 秒后开始第一次检查,避免影响启动性能
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(30)).await;
|
||||
|
||||
loop {
|
||||
// 获取当前配置
|
||||
let (
|
||||
enabled,
|
||||
interval_hours,
|
||||
@@ -333,17 +707,17 @@ pub async fn start_background_update_check(
|
||||
};
|
||||
|
||||
if !enabled {
|
||||
// 如果禁用了自动检查,每小时检查一次配置是否变化
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否需要执行更新检查
|
||||
let service = update_service.read().await;
|
||||
let last_result = service.get_state().await.last_result;
|
||||
let last_result = {
|
||||
let service = update_service.0.read().await;
|
||||
service.get_state().await.last_result
|
||||
};
|
||||
let latest_version = last_result
|
||||
.as_ref()
|
||||
.and_then(|r| r.latest_version.as_deref());
|
||||
.and_then(|result| result.latest_version.as_deref());
|
||||
|
||||
if UpdateCheckService::should_check(
|
||||
last_check,
|
||||
@@ -351,11 +725,7 @@ pub async fn start_background_update_check(
|
||||
skipped_version.as_deref(),
|
||||
latest_version,
|
||||
) {
|
||||
drop(service);
|
||||
|
||||
// 执行更新检查
|
||||
let service = update_service.read().await;
|
||||
let result = service.check_for_updates().await;
|
||||
let result = perform_update_check(&update_service).await;
|
||||
|
||||
tracing::info!(
|
||||
"[更新检查] 当前版本: {}, 最新版本: {:?}, 有更新: {}",
|
||||
@@ -364,28 +734,23 @@ pub async fn start_background_update_check(
|
||||
result.has_update
|
||||
);
|
||||
|
||||
// 更新检查时间
|
||||
if let Some(app_state) = app_handle_clone.try_state::<AppState>() {
|
||||
let mut state = app_state.write().await;
|
||||
state.config.experimental.update_check.last_check_timestamp = result.checked_at;
|
||||
let _ = config::save_config(&state.config);
|
||||
}
|
||||
|
||||
// 如果有更新且启用了通知,打开独立的更新提醒窗口
|
||||
if result.has_update && show_notification {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let in_remind_later = remind_later_until.is_some_and(|ts| ts > now);
|
||||
let in_backoff = next_notify_after.is_some_and(|ts| ts > now);
|
||||
let now = current_unix_timestamp();
|
||||
let in_remind_later =
|
||||
remind_later_until.is_some_and(|timestamp| timestamp > now);
|
||||
let in_backoff = next_notify_after.is_some_and(|timestamp| timestamp > now);
|
||||
let same_version_daily_limited =
|
||||
result.latest_version.as_ref().is_some_and(|latest| {
|
||||
last_notified_version.as_ref() == Some(latest)
|
||||
&& now < last_notified_at.saturating_add(DAY_SECONDS)
|
||||
});
|
||||
|
||||
// 检查是否跳过了此版本
|
||||
let should_notify = result
|
||||
.latest_version
|
||||
.as_ref()
|
||||
@@ -402,28 +767,77 @@ pub async fn start_background_update_check(
|
||||
update_config.last_notified_at = now;
|
||||
update_config.notification_shown_count =
|
||||
update_config.notification_shown_count.saturating_add(1);
|
||||
if update_config.next_notify_after.is_some_and(|ts| ts <= now) {
|
||||
if update_config
|
||||
.next_notify_after
|
||||
.is_some_and(|timestamp| timestamp <= now)
|
||||
{
|
||||
update_config.next_notify_after = None;
|
||||
}
|
||||
let _ = config::save_config(&state.config);
|
||||
}
|
||||
|
||||
// 打开独立的更新提醒窗口 - 必须在主线程执行
|
||||
let app_handle_for_ui = app_handle_clone.clone();
|
||||
let result_clone = result.clone();
|
||||
let _ = app_handle_clone.run_on_main_thread(move || {
|
||||
if let Err(e) =
|
||||
if let Err(error) =
|
||||
update_window::open_update_window(&app_handle_for_ui, &result_clone)
|
||||
{
|
||||
tracing::error!("[更新检查] 打开更新窗口失败: {}", e);
|
||||
tracing::error!("[更新检查] 打开更新窗口失败: {}", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 每小时检查一次是否需要执行更新检查
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_update_cache_fresh() {
|
||||
let cache = UpdateCheckCache {
|
||||
latest: Some("0.92.0".to_string()),
|
||||
download_url: Some(release_tag_url("0.92.0")),
|
||||
release_notes: Some("notes".to_string()),
|
||||
pub_date: Some("2026-03-21T00:00:00Z".to_string()),
|
||||
last_checked_unix: 100,
|
||||
};
|
||||
|
||||
assert!(is_update_cache_fresh(&cache, 150, 60));
|
||||
assert!(!is_update_cache_fresh(&cache, 170, 60));
|
||||
|
||||
let cache_without_latest = UpdateCheckCache {
|
||||
latest: None,
|
||||
..cache
|
||||
};
|
||||
assert!(!is_update_cache_fresh(&cache_without_latest, 120, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_update_info_from_manifest() {
|
||||
let manifest = StaticUpdateManifest {
|
||||
version: "v0.94.0".to_string(),
|
||||
notes: Some("bug fixes".to_string()),
|
||||
pub_date: Some("2026-03-21T00:00:00Z".to_string()),
|
||||
platforms: HashMap::from([(
|
||||
current_platform_key()
|
||||
.unwrap_or("windows-x86_64")
|
||||
.to_string(),
|
||||
StaticUpdatePlatform {
|
||||
url: "https://example.com/lime.nsis.zip".to_string(),
|
||||
signature: Some("sig".to_string()),
|
||||
},
|
||||
)]),
|
||||
};
|
||||
|
||||
let info = build_update_info_from_manifest(manifest);
|
||||
assert_eq!(info.latest_version.as_deref(), Some("0.94.0"));
|
||||
assert!(info.has_update);
|
||||
assert_eq!(info.error, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,123 @@ pub(super) async fn try_handle(
|
||||
.map_err(|e| format!("清理记忆失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_get_effective_sources" => {
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let args = args_or_default(args);
|
||||
let working_dir = args
|
||||
.get("workingDir")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let active_relative_path = args
|
||||
.get("activeRelativePath")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_get_effective_sources(
|
||||
global_config,
|
||||
working_dir,
|
||||
active_relative_path,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("获取有效记忆来源失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_get_auto_index" => {
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let args = args_or_default(args);
|
||||
let working_dir = args
|
||||
.get("workingDir")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_get_auto_index(
|
||||
global_config,
|
||||
working_dir,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("获取自动记忆索引失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_toggle_auto" => {
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let args = args_or_default(args);
|
||||
let enabled = args
|
||||
.get("enabled")
|
||||
.and_then(|value| value.as_bool())
|
||||
.ok_or_else(|| "缺少参数: enabled".to_string())?;
|
||||
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_toggle_auto(global_config, enabled)
|
||||
.await
|
||||
.map_err(|e| format!("切换自动记忆失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_update_auto_note" => {
|
||||
let app_handle = require_app_handle(state)?;
|
||||
let args = args_or_default(args);
|
||||
let working_dir = args
|
||||
.get("workingDir")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let note = args
|
||||
.get("note")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string)
|
||||
.ok_or_else(|| "缺少参数: note".to_string())?;
|
||||
let topic = args
|
||||
.get("topic")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let global_config = app_handle.state::<crate::config::GlobalConfigManagerState>();
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_update_auto_note(
|
||||
global_config,
|
||||
working_dir,
|
||||
note,
|
||||
topic,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("更新自动记忆失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_scaffold_runtime_agents_template" => {
|
||||
let args = args_or_default(args);
|
||||
let target = serde_json::from_value(
|
||||
args.get("target")
|
||||
.cloned()
|
||||
.ok_or_else(|| "缺少参数: target".to_string())?,
|
||||
)?;
|
||||
let working_dir = args
|
||||
.get("workingDir")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
let overwrite = args.get("overwrite").and_then(|value| value.as_bool());
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_scaffold_runtime_agents_template(
|
||||
target,
|
||||
working_dir,
|
||||
overwrite,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("生成运行时 AGENTS 模板失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
"memory_ensure_workspace_local_agents_gitignore" => {
|
||||
let args = args_or_default(args);
|
||||
let working_dir = args
|
||||
.get("workingDir")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToString::to_string);
|
||||
serde_json::to_value(
|
||||
crate::commands::memory_management_cmd::memory_ensure_workspace_local_agents_gitignore(
|
||||
working_dir,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("更新 .gitignore 失败: {e}"))?,
|
||||
)?
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
use crate::commands::provider_pool_cmd::check_playwright_available;
|
||||
use crate::database::DbConnection;
|
||||
use lime_services::api_key_provider_service::ApiKeyProviderService;
|
||||
use lime_services::model_service::ModelService;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClawSolutionReadiness {
|
||||
Ready,
|
||||
NeedsSetup,
|
||||
NeedsCapability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClawSolutionActionType {
|
||||
FillInput,
|
||||
NavigateTheme,
|
||||
LaunchBrowserAssist,
|
||||
EnableTeamMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ClawSolutionReasonCode {
|
||||
MissingModel,
|
||||
MissingBrowserCapability,
|
||||
MissingSkillDependency,
|
||||
TeamRecommended,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClawSolutionSummary {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub output_hint: String,
|
||||
pub recommended_capabilities: Vec<String>,
|
||||
pub readiness: ClawSolutionReadiness,
|
||||
pub readiness_message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason_code: Option<ClawSolutionReasonCode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClawSolutionDetail {
|
||||
#[serde(flatten)]
|
||||
pub summary: ClawSolutionSummary,
|
||||
pub starter_prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme_target: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub followup_mode: Option<String>,
|
||||
pub capability_tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClawSolutionReadinessResult {
|
||||
pub solution_id: String,
|
||||
pub readiness: ClawSolutionReadiness,
|
||||
pub readiness_message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason_code: Option<ClawSolutionReasonCode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClawSolutionContext {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user_input: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClawSolutionPreparation {
|
||||
pub solution_id: String,
|
||||
pub action_type: ClawSolutionActionType,
|
||||
pub prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme_target: Option<String>,
|
||||
pub should_launch_browser_assist: bool,
|
||||
pub should_enable_team_mode: bool,
|
||||
pub readiness: ClawSolutionReadiness,
|
||||
pub readiness_message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason_code: Option<ClawSolutionReasonCode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ClawSolutionDefinition {
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
summary: &'static str,
|
||||
output_hint: &'static str,
|
||||
starter_prompt: &'static str,
|
||||
theme_target: Option<&'static str>,
|
||||
followup_mode: Option<&'static str>,
|
||||
capability_tags: &'static [&'static str],
|
||||
recommended_capabilities: &'static [&'static str],
|
||||
action_type: ClawSolutionActionType,
|
||||
needs_browser_capability: bool,
|
||||
should_enable_team_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct CapabilitySnapshot {
|
||||
has_available_models: bool,
|
||||
browser_available: bool,
|
||||
}
|
||||
|
||||
const CLAW_SOLUTIONS: [ClawSolutionDefinition; 6] = [
|
||||
ClawSolutionDefinition {
|
||||
id: "web-research-brief",
|
||||
title: "网页研究简报",
|
||||
summary: "快速整理调研范围、关键信息与结论框架,适合先把研究任务落成一版结构化简报。",
|
||||
output_hint: "研究提纲 + 结论简报",
|
||||
starter_prompt: "请围绕这个主题先给我做一版网页研究简报:明确研究目标、关键信息来源、核心发现、风险点,以及接下来最值得继续追踪的问题。",
|
||||
theme_target: None,
|
||||
followup_mode: Some("iterative"),
|
||||
capability_tags: &["research", "brief"],
|
||||
recommended_capabilities: &["模型", "研究"],
|
||||
action_type: ClawSolutionActionType::FillInput,
|
||||
needs_browser_capability: false,
|
||||
should_enable_team_mode: false,
|
||||
},
|
||||
ClawSolutionDefinition {
|
||||
id: "social-post-starter",
|
||||
title: "社媒主稿生成",
|
||||
summary: "先进入社媒专项工作台,再围绕平台语境、结构和表达生成可继续迭代的首稿。",
|
||||
output_hint: "社媒首稿 + 平台结构",
|
||||
starter_prompt: "请先帮我起草一版社媒内容首稿:明确目标受众、平台语境、标题方向、正文结构和可继续扩写的角度。",
|
||||
theme_target: Some("social-media"),
|
||||
followup_mode: Some("gui"),
|
||||
capability_tags: &["social-media", "draft"],
|
||||
recommended_capabilities: &["模型", "社媒主题"],
|
||||
action_type: ClawSolutionActionType::NavigateTheme,
|
||||
needs_browser_capability: false,
|
||||
should_enable_team_mode: false,
|
||||
},
|
||||
ClawSolutionDefinition {
|
||||
id: "frontend-concept",
|
||||
title: "前端概念方案",
|
||||
summary: "快速产出信息架构、关键模块与页面关系,适合产品概念、后台台架或工作台原型讨论。",
|
||||
output_hint: "IA + 模块方案",
|
||||
starter_prompt: "请帮我先整理一版前端概念方案:输出信息架构、核心页面、关键模块、交互流程和第一轮组件拆分建议。",
|
||||
theme_target: None,
|
||||
followup_mode: Some("iterative"),
|
||||
capability_tags: &["frontend", "architecture"],
|
||||
recommended_capabilities: &["模型", "结构化输出"],
|
||||
action_type: ClawSolutionActionType::FillInput,
|
||||
needs_browser_capability: false,
|
||||
should_enable_team_mode: false,
|
||||
},
|
||||
ClawSolutionDefinition {
|
||||
id: "slide-outline",
|
||||
title: "演示提纲草案",
|
||||
summary: "先拿到一版可讲述的演示结构,覆盖封面、问题、观点、案例与行动建议。",
|
||||
output_hint: "PPT 大纲 + 讲述线",
|
||||
starter_prompt: "请基于这个目标先生成一版演示提纲:包含封面定位、目录、核心论点、案例支撑、结论和下一步行动。",
|
||||
theme_target: None,
|
||||
followup_mode: Some("iterative"),
|
||||
capability_tags: &["slides", "outline"],
|
||||
recommended_capabilities: &["模型", "结构化输出"],
|
||||
action_type: ClawSolutionActionType::FillInput,
|
||||
needs_browser_capability: false,
|
||||
should_enable_team_mode: false,
|
||||
},
|
||||
ClawSolutionDefinition {
|
||||
id: "browser-assist-task",
|
||||
title: "浏览器协助办事",
|
||||
summary: "适合登录、表单、网页操作和信息采集任务,进入工作区后直接接管浏览器协助链路。",
|
||||
output_hint: "浏览器任务执行",
|
||||
starter_prompt: "请协助我完成一个浏览器任务:先明确目标网页、目标动作、约束条件和预期结果,再进入执行。",
|
||||
theme_target: None,
|
||||
followup_mode: Some("browser_assist"),
|
||||
capability_tags: &["browser", "automation"],
|
||||
recommended_capabilities: &["模型", "浏览器协助"],
|
||||
action_type: ClawSolutionActionType::LaunchBrowserAssist,
|
||||
needs_browser_capability: true,
|
||||
should_enable_team_mode: false,
|
||||
},
|
||||
ClawSolutionDefinition {
|
||||
id: "team-breakdown",
|
||||
title: "多代理拆任务",
|
||||
summary: "适合需要并行调研、方案拆解或多角色协作的任务,进入后默认启用 team runtime 偏好。",
|
||||
output_hint: "任务拆解 + 分工执行",
|
||||
starter_prompt: "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。",
|
||||
theme_target: None,
|
||||
followup_mode: Some("team_runtime"),
|
||||
capability_tags: &["team", "decomposition"],
|
||||
recommended_capabilities: &["模型", "多代理"],
|
||||
action_type: ClawSolutionActionType::EnableTeamMode,
|
||||
needs_browser_capability: false,
|
||||
should_enable_team_mode: true,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ClawSolutionService;
|
||||
|
||||
impl ClawSolutionService {
|
||||
pub async fn list(&self, db: &DbConnection) -> Result<Vec<ClawSolutionSummary>, String> {
|
||||
let snapshot = self.build_capability_snapshot(db, true).await?;
|
||||
|
||||
Ok(CLAW_SOLUTIONS
|
||||
.iter()
|
||||
.map(|definition| self.to_summary(definition, snapshot))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn detail(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
solution_id: &str,
|
||||
) -> Result<ClawSolutionDetail, String> {
|
||||
let definition = self.find_definition(solution_id)?;
|
||||
let snapshot = self
|
||||
.build_capability_snapshot(db, definition.needs_browser_capability)
|
||||
.await?;
|
||||
|
||||
Ok(self.to_detail(definition, snapshot))
|
||||
}
|
||||
|
||||
pub async fn check_readiness(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
solution_id: &str,
|
||||
) -> Result<ClawSolutionReadinessResult, String> {
|
||||
let definition = self.find_definition(solution_id)?;
|
||||
let snapshot = self
|
||||
.build_capability_snapshot(db, definition.needs_browser_capability)
|
||||
.await?;
|
||||
let readiness = self.resolve_readiness(definition, snapshot);
|
||||
|
||||
Ok(ClawSolutionReadinessResult {
|
||||
solution_id: definition.id.to_string(),
|
||||
readiness: readiness.readiness,
|
||||
readiness_message: readiness.message,
|
||||
reason_code: readiness.reason_code,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn prepare(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
solution_id: &str,
|
||||
context: Option<ClawSolutionContext>,
|
||||
) -> Result<ClawSolutionPreparation, String> {
|
||||
let definition = self.find_definition(solution_id)?;
|
||||
let snapshot = self
|
||||
.build_capability_snapshot(db, definition.needs_browser_capability)
|
||||
.await?;
|
||||
let readiness = self.resolve_readiness(definition, snapshot);
|
||||
let context = context.unwrap_or_default();
|
||||
|
||||
Ok(ClawSolutionPreparation {
|
||||
solution_id: definition.id.to_string(),
|
||||
action_type: definition.action_type,
|
||||
prompt: self.build_prompt(definition, &context),
|
||||
theme_target: definition.theme_target.map(str::to_string),
|
||||
should_launch_browser_assist: definition.needs_browser_capability,
|
||||
should_enable_team_mode: definition.should_enable_team_mode,
|
||||
readiness: readiness.readiness,
|
||||
readiness_message: readiness.message,
|
||||
reason_code: readiness.reason_code,
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_capability_snapshot(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
needs_browser_capability: bool,
|
||||
) -> Result<CapabilitySnapshot, String> {
|
||||
let has_available_models =
|
||||
self.has_current_llm_configuration(db)? || self.has_compat_available_models(db)?;
|
||||
let browser_available = if needs_browser_capability {
|
||||
check_playwright_available().await?.available
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
Ok(CapabilitySnapshot {
|
||||
has_available_models,
|
||||
browser_available,
|
||||
})
|
||||
}
|
||||
|
||||
fn has_current_llm_configuration(&self, db: &DbConnection) -> Result<bool, String> {
|
||||
let providers = ApiKeyProviderService::new().get_all_providers(db)?;
|
||||
|
||||
Ok(providers
|
||||
.into_iter()
|
||||
.any(|item| item.provider.enabled && item.api_keys.iter().any(|key| key.enabled)))
|
||||
}
|
||||
|
||||
fn has_compat_available_models(&self, db: &DbConnection) -> Result<bool, String> {
|
||||
Ok(!ModelService::new().get_all_available_models(db)?.is_empty())
|
||||
}
|
||||
|
||||
fn find_definition(
|
||||
&self,
|
||||
solution_id: &str,
|
||||
) -> Result<&'static ClawSolutionDefinition, String> {
|
||||
CLAW_SOLUTIONS
|
||||
.iter()
|
||||
.find(|definition| definition.id == solution_id)
|
||||
.ok_or_else(|| format!("未找到 Claw 方案: {solution_id}"))
|
||||
}
|
||||
|
||||
fn to_summary(
|
||||
&self,
|
||||
definition: &ClawSolutionDefinition,
|
||||
snapshot: CapabilitySnapshot,
|
||||
) -> ClawSolutionSummary {
|
||||
let readiness = self.resolve_readiness(definition, snapshot);
|
||||
ClawSolutionSummary {
|
||||
id: definition.id.to_string(),
|
||||
title: definition.title.to_string(),
|
||||
summary: definition.summary.to_string(),
|
||||
output_hint: definition.output_hint.to_string(),
|
||||
recommended_capabilities: definition
|
||||
.recommended_capabilities
|
||||
.iter()
|
||||
.map(|item| (*item).to_string())
|
||||
.collect(),
|
||||
readiness: readiness.readiness,
|
||||
readiness_message: readiness.message,
|
||||
reason_code: readiness.reason_code,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_detail(
|
||||
&self,
|
||||
definition: &ClawSolutionDefinition,
|
||||
snapshot: CapabilitySnapshot,
|
||||
) -> ClawSolutionDetail {
|
||||
ClawSolutionDetail {
|
||||
summary: self.to_summary(definition, snapshot),
|
||||
starter_prompt: definition.starter_prompt.to_string(),
|
||||
theme_target: definition.theme_target.map(str::to_string),
|
||||
followup_mode: definition.followup_mode.map(str::to_string),
|
||||
capability_tags: definition
|
||||
.capability_tags
|
||||
.iter()
|
||||
.map(|item| (*item).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_prompt(
|
||||
&self,
|
||||
definition: &ClawSolutionDefinition,
|
||||
context: &ClawSolutionContext,
|
||||
) -> String {
|
||||
let user_input = context
|
||||
.user_input
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
match user_input {
|
||||
Some(value) => format!("{}\n\n补充上下文:{}", definition.starter_prompt, value),
|
||||
None => definition.starter_prompt.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_readiness(
|
||||
&self,
|
||||
definition: &ClawSolutionDefinition,
|
||||
snapshot: CapabilitySnapshot,
|
||||
) -> ResolvedReadiness {
|
||||
if !snapshot.has_available_models {
|
||||
return ResolvedReadiness::new(
|
||||
ClawSolutionReadiness::NeedsSetup,
|
||||
"先配置可用模型后再开始",
|
||||
Some(ClawSolutionReasonCode::MissingModel),
|
||||
);
|
||||
}
|
||||
|
||||
if definition.needs_browser_capability && !snapshot.browser_available {
|
||||
return ResolvedReadiness::new(
|
||||
ClawSolutionReadiness::NeedsCapability,
|
||||
"先安装或连接可用浏览器能力",
|
||||
Some(ClawSolutionReasonCode::MissingBrowserCapability),
|
||||
);
|
||||
}
|
||||
|
||||
if definition.should_enable_team_mode {
|
||||
return ResolvedReadiness::new(
|
||||
ClawSolutionReadiness::Ready,
|
||||
"可直接开始,进入后会启用多代理偏好",
|
||||
Some(ClawSolutionReasonCode::TeamRecommended),
|
||||
);
|
||||
}
|
||||
|
||||
ResolvedReadiness::new(ClawSolutionReadiness::Ready, "可直接开始", None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResolvedReadiness {
|
||||
readiness: ClawSolutionReadiness,
|
||||
message: String,
|
||||
reason_code: Option<ClawSolutionReasonCode>,
|
||||
}
|
||||
|
||||
impl ResolvedReadiness {
|
||||
fn new(
|
||||
readiness: ClawSolutionReadiness,
|
||||
message: impl Into<String>,
|
||||
reason_code: Option<ClawSolutionReasonCode>,
|
||||
) -> Self {
|
||||
Self {
|
||||
readiness,
|
||||
message: message.into(),
|
||||
reason_code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::schema::create_tables;
|
||||
use lime_core::database::dao::api_key_provider::ApiProviderType;
|
||||
use lime_core::database::dao::provider_pool::ProviderPoolDao;
|
||||
use lime_core::models::provider_pool_model::{
|
||||
CredentialData, PoolProviderType, ProviderCredential,
|
||||
};
|
||||
use lime_services::api_key_provider_service::ApiKeyProviderService;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn setup_db() -> DbConnection {
|
||||
let conn = Connection::open_in_memory().expect("创建内存数据库失败");
|
||||
create_tables(&conn).expect("创建数据表失败");
|
||||
Arc::new(Mutex::new(conn))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_should_mark_ready_when_api_key_provider_is_configured() {
|
||||
let db = setup_db();
|
||||
let api_key_service = ApiKeyProviderService::new();
|
||||
let provider = api_key_service
|
||||
.add_custom_provider(
|
||||
&db,
|
||||
"测试 Anthropic 兼容 Provider".to_string(),
|
||||
ApiProviderType::AnthropicCompatible,
|
||||
"https://example.com/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("创建自定义 Provider 失败");
|
||||
|
||||
api_key_service
|
||||
.update_provider(
|
||||
&db,
|
||||
&provider.id,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(vec!["glm-4.7".to_string()]),
|
||||
)
|
||||
.expect("更新 Provider 失败");
|
||||
|
||||
api_key_service
|
||||
.add_api_key(&db, &provider.id, "sk-test", Some("主 Key".to_string()))
|
||||
.expect("添加 API Key 失败");
|
||||
|
||||
let summaries = ClawSolutionService::default()
|
||||
.list(&db)
|
||||
.await
|
||||
.expect("获取 Claw 方案列表失败");
|
||||
|
||||
assert!(
|
||||
summaries
|
||||
.iter()
|
||||
.all(|summary| summary.readiness == ClawSolutionReadiness::Ready),
|
||||
"当前 API Key Provider 已配置时,Claw 不应继续提示先配置模型"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_should_keep_legacy_provider_pool_as_compat_fallback() {
|
||||
let db = setup_db();
|
||||
let mut credential = ProviderCredential::new(
|
||||
PoolProviderType::OpenAI,
|
||||
CredentialData::OpenAIKey {
|
||||
api_key: "sk-test".to_string(),
|
||||
base_url: None,
|
||||
},
|
||||
);
|
||||
credential.supported_models = vec!["gpt-4o".to_string()];
|
||||
|
||||
{
|
||||
let conn = db.lock().expect("锁定数据库失败");
|
||||
ProviderPoolDao::insert(&conn, &credential).expect("插入旧 provider_pool 凭证失败");
|
||||
}
|
||||
|
||||
let summaries = ClawSolutionService::default()
|
||||
.list(&db)
|
||||
.await
|
||||
.expect("获取 Claw 方案列表失败");
|
||||
|
||||
assert!(
|
||||
summaries
|
||||
.iter()
|
||||
.all(|summary| summary.readiness == ClawSolutionReadiness::Ready),
|
||||
"compat 口径下的 provider_pool 可用模型仍应保持可启动"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
//! 将设置页中的记忆画像与配置化记忆来源统一装配为可注入到 system prompt
|
||||
//! 的单一记忆指令片段,避免调用方继续各自决定拼装顺序。
|
||||
|
||||
use lime_agent::RUNTIME_AGENTS_PROMPT_MARKER;
|
||||
use lime_core::config::Config;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::services::memory_source_resolver_service::build_memory_sources_prompt;
|
||||
use crate::services::memory_source_resolver_service::build_memory_sources_prompt_with_options;
|
||||
|
||||
const MEMORY_PROFILE_PROMPT_MARKER: &str = "【用户记忆画像偏好】";
|
||||
const MEMORY_SOURCE_PROMPT_MARKER: &str = "【记忆来源补充指令】";
|
||||
@@ -106,13 +107,20 @@ fn build_memory_profile_prompt(config: &Config) -> Option<String> {
|
||||
fn build_memory_sources_prompt_for_context(
|
||||
config: &Config,
|
||||
context: MemoryPromptContext<'_>,
|
||||
skip_runtime_agents_overlap: bool,
|
||||
) -> Option<String> {
|
||||
let working_dir = context.working_dir?;
|
||||
if !config.memory.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
build_memory_sources_prompt(config, working_dir, context.active_relative_path, 4000)
|
||||
build_memory_sources_prompt_with_options(
|
||||
config,
|
||||
working_dir,
|
||||
context.active_relative_path,
|
||||
4000,
|
||||
skip_runtime_agents_overlap,
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_prompt_section(
|
||||
@@ -145,7 +153,7 @@ pub fn build_memory_prompt(config: &Config, context: MemoryPromptContext<'_>) ->
|
||||
|
||||
merge_prompt_section(
|
||||
with_profile,
|
||||
build_memory_sources_prompt_for_context(config, context),
|
||||
build_memory_sources_prompt_for_context(config, context, false),
|
||||
MEMORY_SOURCE_PROMPT_MARKER,
|
||||
)
|
||||
}
|
||||
@@ -159,6 +167,9 @@ pub fn merge_system_prompt_with_memory_context(
|
||||
config: &Config,
|
||||
context: MemoryPromptContext<'_>,
|
||||
) -> Option<String> {
|
||||
let skip_runtime_agents_overlap = base_prompt
|
||||
.as_deref()
|
||||
.is_some_and(|prompt| prompt.contains(RUNTIME_AGENTS_PROMPT_MARKER));
|
||||
let with_profile = merge_prompt_section(
|
||||
base_prompt,
|
||||
build_memory_profile_prompt(config),
|
||||
@@ -167,7 +178,7 @@ pub fn merge_system_prompt_with_memory_context(
|
||||
|
||||
merge_prompt_section(
|
||||
with_profile,
|
||||
build_memory_sources_prompt_for_context(config, context),
|
||||
build_memory_sources_prompt_for_context(config, context, skip_runtime_agents_overlap),
|
||||
MEMORY_SOURCE_PROMPT_MARKER,
|
||||
)
|
||||
}
|
||||
@@ -175,6 +186,7 @@ pub fn merge_system_prompt_with_memory_context(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lime_agent::RUNTIME_AGENTS_PROMPT_MARKER;
|
||||
use lime_core::config::Config;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
@@ -237,15 +249,19 @@ mod tests {
|
||||
#[test]
|
||||
fn should_merge_memory_sources_without_profile_data() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 偏好简洁输出")
|
||||
.expect("write memory file");
|
||||
fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir");
|
||||
fs::write(
|
||||
tmp.path().join(".lime/AGENTS.md"),
|
||||
"# 项目记忆\n- 偏好简洁输出",
|
||||
)
|
||||
.expect("write memory file");
|
||||
|
||||
let mut config = Config::default();
|
||||
config.memory.enabled = true;
|
||||
config.memory.profile = Some(Default::default());
|
||||
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
|
||||
config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
|
||||
config.memory.sources.project_rule_dirs = Vec::new();
|
||||
|
||||
let merged = merge_system_prompt_with_memory_context(
|
||||
@@ -262,7 +278,8 @@ mod tests {
|
||||
#[test]
|
||||
fn should_build_combined_memory_prompt() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 保持简洁")
|
||||
fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir");
|
||||
fs::write(tmp.path().join(".lime/AGENTS.md"), "# 项目记忆\n- 保持简洁")
|
||||
.expect("write memory file");
|
||||
|
||||
let mut config = Config::default();
|
||||
@@ -270,7 +287,7 @@ mod tests {
|
||||
let mut profile = config.memory.profile.clone().unwrap_or_default();
|
||||
profile.current_status = Some("高级开发者".to_string());
|
||||
config.memory.profile = Some(profile);
|
||||
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
|
||||
config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
|
||||
config.memory.sources.project_rule_dirs = Vec::new();
|
||||
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
@@ -284,4 +301,40 @@ mod tests {
|
||||
assert!(prompt.contains("【记忆来源补充指令】"));
|
||||
assert!(prompt.contains("保持简洁"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_skip_runtime_agent_overlap_sources_but_keep_local_memory() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir");
|
||||
fs::write(tmp.path().join(".lime/AGENTS.md"), "# 项目记忆\n- 保持简洁")
|
||||
.expect("write workspace agents");
|
||||
fs::write(
|
||||
tmp.path().join(".lime/AGENTS.local.md"),
|
||||
"# 本机补充\n- 优先使用当前机器已安装工具",
|
||||
)
|
||||
.expect("write local agents");
|
||||
|
||||
let mut config = Config::default();
|
||||
config.memory.enabled = true;
|
||||
config.memory.profile = Some(Default::default());
|
||||
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
|
||||
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
|
||||
config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
|
||||
config.memory.sources.project_local_memory_path = Some(".lime/AGENTS.local.md".to_string());
|
||||
config.memory.sources.project_rule_dirs = Vec::new();
|
||||
|
||||
let base = Some(format!(
|
||||
"{RUNTIME_AGENTS_PROMPT_MARKER}\n### Workspace 运行时指令 (/tmp/workspace/.lime/AGENTS.md)\n# 项目记忆\n- 保持简洁"
|
||||
));
|
||||
let merged = merge_system_prompt_with_memory_context(
|
||||
base,
|
||||
&config,
|
||||
MemoryPromptContext::with_working_dir(tmp.path()),
|
||||
)
|
||||
.expect("should merge prompt");
|
||||
|
||||
assert_eq!(merged.matches("保持简洁").count(), 1);
|
||||
assert!(merged.contains("【记忆来源补充指令】"));
|
||||
assert!(merged.contains("优先使用当前机器已安装工具"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ pub struct EffectiveMemorySourcesResponse {
|
||||
pub struct MemorySourceResolution {
|
||||
pub response: EffectiveMemorySourcesResponse,
|
||||
pub prompt_segments: Vec<String>,
|
||||
pub prompt_sources: Vec<MemoryPromptSegment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoryPromptSegment {
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// 解析有效记忆来源
|
||||
@@ -73,6 +81,7 @@ pub fn resolve_effective_sources(
|
||||
|
||||
let mut sources = Vec::new();
|
||||
let mut prompt_segments = Vec::new();
|
||||
let mut prompt_sources = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
// 1. managed policy
|
||||
@@ -90,6 +99,7 @@ pub fn resolve_effective_sources(
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
);
|
||||
|
||||
// 2. user memory
|
||||
@@ -107,6 +117,7 @@ pub fn resolve_effective_sources(
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
);
|
||||
|
||||
// 3. cross-thread durable memory (`/memories/...`)
|
||||
@@ -116,16 +127,13 @@ pub fn resolve_effective_sources(
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
);
|
||||
|
||||
// 4. project hierarchy memory + rules
|
||||
let ancestors = collect_ancestor_dirs(working_dir);
|
||||
for ancestor in &ancestors {
|
||||
for rel in &memory.sources.project_memory_paths {
|
||||
if rel.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let candidate = ancestor.join(rel);
|
||||
for rel in &memory.sources.project_memory_paths {
|
||||
for candidate in resolve_project_relative_candidates(working_dir, &ancestors, rel) {
|
||||
resolve_file_source(
|
||||
"project_memory",
|
||||
&candidate,
|
||||
@@ -134,17 +142,21 @@ pub fn resolve_effective_sources(
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(project_local_rel) = memory
|
||||
.sources
|
||||
.project_local_memory_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
if let Some(project_local_rel) = memory
|
||||
.sources
|
||||
.project_local_memory_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
for candidate in
|
||||
resolve_project_relative_candidates(working_dir, &ancestors, project_local_rel)
|
||||
{
|
||||
let candidate = ancestor.join(project_local_rel);
|
||||
resolve_file_source(
|
||||
"project_local",
|
||||
&candidate,
|
||||
@@ -153,9 +165,12 @@ pub fn resolve_effective_sources(
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for ancestor in &ancestors {
|
||||
for rel in &memory.sources.project_rule_dirs {
|
||||
if rel.trim().is_empty() {
|
||||
continue;
|
||||
@@ -168,6 +183,7 @@ pub fn resolve_effective_sources(
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +205,7 @@ pub fn resolve_effective_sources(
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
);
|
||||
}
|
||||
for rel in &memory.sources.project_rule_dirs {
|
||||
@@ -203,6 +220,7 @@ pub fn resolve_effective_sources(
|
||||
&mut seen,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -214,6 +232,7 @@ pub fn resolve_effective_sources(
|
||||
working_dir,
|
||||
&mut sources,
|
||||
&mut prompt_segments,
|
||||
&mut prompt_sources,
|
||||
&mut seen,
|
||||
);
|
||||
|
||||
@@ -230,6 +249,7 @@ pub fn resolve_effective_sources(
|
||||
MemorySourceResolution {
|
||||
response,
|
||||
prompt_segments,
|
||||
prompt_sources,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,25 +259,53 @@ pub fn build_memory_sources_prompt(
|
||||
working_dir: &Path,
|
||||
active_relative_path: Option<&str>,
|
||||
max_chars: usize,
|
||||
) -> Option<String> {
|
||||
build_memory_sources_prompt_with_options(
|
||||
config,
|
||||
working_dir,
|
||||
active_relative_path,
|
||||
max_chars,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_memory_sources_prompt_with_options(
|
||||
config: &Config,
|
||||
working_dir: &Path,
|
||||
active_relative_path: Option<&str>,
|
||||
max_chars: usize,
|
||||
skip_runtime_agents_overlap: bool,
|
||||
) -> Option<String> {
|
||||
let resolution = resolve_effective_sources(config, working_dir, active_relative_path);
|
||||
if resolution.prompt_segments.is_empty() {
|
||||
if resolution.prompt_sources.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut output = String::from("【记忆来源补充指令】\n");
|
||||
output.push_str("以下内容来自配置化记忆来源,请优先遵循:\n");
|
||||
let runtime_agent_paths = if skip_runtime_agents_overlap {
|
||||
runtime_agent_overlap_paths(working_dir)
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
let mut used = 0usize;
|
||||
for segment in resolution.prompt_segments {
|
||||
if segment.trim().is_empty() {
|
||||
for segment in resolution.prompt_sources {
|
||||
if should_skip_runtime_agent_overlap(&segment, &runtime_agent_paths) {
|
||||
continue;
|
||||
}
|
||||
let rendered = format!(
|
||||
"### {} ({})\n{}",
|
||||
segment.title, segment.path, segment.content
|
||||
);
|
||||
if rendered.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if used >= max_chars {
|
||||
break;
|
||||
}
|
||||
let remaining = max_chars.saturating_sub(used);
|
||||
let clipped = clip_text(&segment, remaining);
|
||||
let clipped = clip_text(&rendered, remaining);
|
||||
if clipped.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -274,6 +322,23 @@ pub fn build_memory_sources_prompt(
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_agent_overlap_paths(working_dir: &Path) -> HashSet<PathBuf> {
|
||||
let mut paths = HashSet::new();
|
||||
paths.insert(normalize_path(&app_paths::best_effort_user_memory_path()));
|
||||
paths.insert(normalize_path(&working_dir.join(".lime").join("AGENTS.md")));
|
||||
paths
|
||||
}
|
||||
|
||||
fn should_skip_runtime_agent_overlap(
|
||||
segment: &MemoryPromptSegment,
|
||||
runtime_agent_paths: &HashSet<PathBuf>,
|
||||
) -> bool {
|
||||
if runtime_agent_paths.is_empty() {
|
||||
return false;
|
||||
}
|
||||
runtime_agent_paths.contains(&normalize_path(Path::new(&segment.path)))
|
||||
}
|
||||
|
||||
fn resolve_file_source(
|
||||
kind: &str,
|
||||
file_path: &Path,
|
||||
@@ -282,6 +347,7 @@ fn resolve_file_source(
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
output: &mut Vec<EffectiveMemorySource>,
|
||||
prompt_segments: &mut Vec<String>,
|
||||
prompt_sources: &mut Vec<MemoryPromptSegment>,
|
||||
) {
|
||||
resolve_file_source_with_display_path(
|
||||
kind,
|
||||
@@ -292,6 +358,7 @@ fn resolve_file_source(
|
||||
seen,
|
||||
output,
|
||||
prompt_segments,
|
||||
prompt_sources,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,6 +371,7 @@ fn resolve_file_source_with_display_path(
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
output: &mut Vec<EffectiveMemorySource>,
|
||||
prompt_segments: &mut Vec<String>,
|
||||
prompt_sources: &mut Vec<MemoryPromptSegment>,
|
||||
) {
|
||||
let normalized = normalize_path(file_path);
|
||||
if !seen.insert(normalized.clone()) {
|
||||
@@ -359,6 +427,11 @@ fn resolve_file_source_with_display_path(
|
||||
|
||||
if loaded {
|
||||
prompt_segments.push(format!("### {} ({})\n{}", kind, display_path, content));
|
||||
prompt_sources.push(MemoryPromptSegment {
|
||||
title: kind.to_string(),
|
||||
path: display_path,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -382,6 +455,7 @@ fn resolve_durable_memory_sources(
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
output: &mut Vec<EffectiveMemorySource>,
|
||||
prompt_segments: &mut Vec<String>,
|
||||
prompt_sources: &mut Vec<MemoryPromptSegment>,
|
||||
) {
|
||||
let root = match resolve_durable_memory_root() {
|
||||
Ok(path) => path,
|
||||
@@ -454,6 +528,7 @@ fn resolve_durable_memory_sources(
|
||||
seen,
|
||||
output,
|
||||
prompt_segments,
|
||||
prompt_sources,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -465,6 +540,7 @@ fn resolve_rule_sources(
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
output: &mut Vec<EffectiveMemorySource>,
|
||||
prompt_segments: &mut Vec<String>,
|
||||
prompt_sources: &mut Vec<MemoryPromptSegment>,
|
||||
) {
|
||||
let normalized = normalize_path(rule_dir);
|
||||
let dir_key = normalized.join("__rules_dir__");
|
||||
@@ -547,6 +623,11 @@ fn resolve_rule_sources(
|
||||
normalized_rule.display(),
|
||||
rule.content
|
||||
));
|
||||
prompt_sources.push(MemoryPromptSegment {
|
||||
title: format!("规则: {}", rule.title),
|
||||
path: normalized_rule.to_string_lossy().to_string(),
|
||||
content: rule.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,6 +637,7 @@ fn resolve_auto_memory_source(
|
||||
working_dir: &Path,
|
||||
output: &mut Vec<EffectiveMemorySource>,
|
||||
prompt_segments: &mut Vec<String>,
|
||||
prompt_sources: &mut Vec<MemoryPromptSegment>,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
) {
|
||||
let auto_root = resolve_auto_memory_root(working_dir, &memory_config.auto);
|
||||
@@ -599,6 +681,11 @@ fn resolve_auto_memory_source(
|
||||
entry_path.display(),
|
||||
idx.preview_lines.join("\n")
|
||||
));
|
||||
prompt_sources.push(MemoryPromptSegment {
|
||||
title: "auto_memory".to_string(),
|
||||
path: entry_path.to_string_lossy().to_string(),
|
||||
content: idx.preview_lines.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -762,7 +849,7 @@ fn expand_path(path: &str, working_dir: Option<&Path>) -> PathBuf {
|
||||
|
||||
fn default_user_memory_path() -> PathBuf {
|
||||
app_paths::resolve_user_memory_path()
|
||||
.unwrap_or_else(|_| app_paths::best_effort_app_data_file("AGENTS.md"))
|
||||
.unwrap_or_else(|_| app_paths::best_effort_user_memory_path())
|
||||
}
|
||||
|
||||
fn default_managed_policy_path() -> PathBuf {
|
||||
@@ -786,6 +873,31 @@ fn normalize_path(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn resolve_project_relative_candidates(
|
||||
working_dir: &Path,
|
||||
ancestors: &[PathBuf],
|
||||
relative_path: &str,
|
||||
) -> Vec<PathBuf> {
|
||||
let trimmed = relative_path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if is_workspace_local_instruction_path(trimmed) {
|
||||
return vec![working_dir.join(trimmed)];
|
||||
}
|
||||
|
||||
ancestors
|
||||
.iter()
|
||||
.map(|ancestor| ancestor.join(trimmed))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_workspace_local_instruction_path(relative_path: &str) -> bool {
|
||||
let normalized = relative_path.trim_start_matches("./").replace('\\', "/");
|
||||
normalized.starts_with(".lime/")
|
||||
}
|
||||
|
||||
fn find_git_root(start: &Path) -> Option<PathBuf> {
|
||||
let mut current = if start.is_file() {
|
||||
start.parent()?.to_path_buf()
|
||||
@@ -861,12 +973,13 @@ mod tests {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join(".agents/rules")).expect("create rules");
|
||||
fs::write(root.join("AGENTS.md"), "# 项目记忆\n- use rust").expect("write agents");
|
||||
fs::create_dir_all(root.join(".lime")).expect("create .lime dir");
|
||||
fs::write(root.join(".lime/AGENTS.md"), "# 项目记忆\n- use rust").expect("write agents");
|
||||
fs::write(root.join(".agents/rules/general.md"), "# 规则\n- KISS").expect("write rule");
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.memory.enabled = true;
|
||||
cfg.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
|
||||
cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
|
||||
cfg.memory.sources.project_rule_dirs = vec![".agents/rules".to_string()];
|
||||
cfg.memory.resolve.follow_imports = true;
|
||||
cfg.memory.resolve.import_max_depth = 3;
|
||||
@@ -884,11 +997,12 @@ mod tests {
|
||||
let ext = tmp.path().join("extra");
|
||||
fs::create_dir_all(&root).expect("create main");
|
||||
fs::create_dir_all(&ext).expect("create extra");
|
||||
fs::write(ext.join("AGENTS.md"), "extra memory").expect("write extra agents");
|
||||
fs::create_dir_all(ext.join(".lime")).expect("create extra .lime");
|
||||
fs::write(ext.join(".lime/AGENTS.md"), "extra memory").expect("write extra agents");
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.memory.enabled = true;
|
||||
cfg.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
|
||||
cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
|
||||
cfg.memory.resolve.load_additional_dirs_memory = true;
|
||||
cfg.memory.resolve.additional_dirs = vec![ext.to_string_lossy().to_string()];
|
||||
|
||||
@@ -942,4 +1056,35 @@ mod tests {
|
||||
.iter()
|
||||
.any(|segment| segment.contains("/memories/MEMORY.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_local_instruction_path_should_not_walk_ancestors() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let project_root = tmp.path().join("repo");
|
||||
let nested = project_root.join("workspace");
|
||||
fs::create_dir_all(project_root.join(".git")).expect("create git marker");
|
||||
fs::create_dir_all(project_root.join(".lime")).expect("create root .lime");
|
||||
fs::create_dir_all(nested.join(".lime")).expect("create nested .lime");
|
||||
fs::write(project_root.join(".lime/AGENTS.md"), "root agents").expect("write root agents");
|
||||
fs::write(nested.join(".lime/AGENTS.md"), "workspace agents")
|
||||
.expect("write workspace agents");
|
||||
|
||||
let mut cfg = Config::default();
|
||||
cfg.memory.enabled = true;
|
||||
cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()];
|
||||
|
||||
let resolved = resolve_effective_sources(&cfg, &nested, None);
|
||||
let loaded_sources: Vec<&EffectiveMemorySource> = resolved
|
||||
.response
|
||||
.sources
|
||||
.iter()
|
||||
.filter(|source| source.kind == "project_memory" && source.loaded)
|
||||
.collect();
|
||||
|
||||
assert_eq!(loaded_sources.len(), 1);
|
||||
assert!(loaded_sources[0].path.ends_with(".lime/AGENTS.md"));
|
||||
assert_eq!(resolved.prompt_segments.len(), 1);
|
||||
assert!(resolved.prompt_segments[0].contains("workspace agents"));
|
||||
assert!(!resolved.prompt_segments[0].contains("root agents"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod browser_environment_service;
|
||||
pub mod browser_profile_service;
|
||||
pub mod browser_runtime_window;
|
||||
pub mod chat_history_service;
|
||||
pub mod claw_solution_service;
|
||||
pub mod conversation_statistics_service;
|
||||
pub mod environment_service;
|
||||
pub mod execution_tracker_service;
|
||||
@@ -21,6 +22,7 @@ pub mod memory_rules_loader_service;
|
||||
pub mod memory_source_resolver_service;
|
||||
pub mod novel_service;
|
||||
pub mod openclaw_service;
|
||||
pub mod runtime_agents_template_service;
|
||||
pub mod sysinfo_service;
|
||||
pub mod update_check_service;
|
||||
pub mod update_window;
|
||||
|
||||
@@ -10,6 +10,7 @@ use lime_core::openclaw_install::{
|
||||
select_best_semver_candidate as core_select_best_semver_candidate,
|
||||
select_preferred_path_candidate as core_select_preferred_path_candidate,
|
||||
shell_command_escape_for as core_shell_command_escape_for,
|
||||
shell_command_invocation_prefix_for as core_shell_command_invocation_prefix_for,
|
||||
shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for,
|
||||
shell_path_assignment_for as core_shell_path_assignment_for,
|
||||
windows_manual_install_message as core_windows_manual_install_message,
|
||||
@@ -231,7 +232,11 @@ enum ResolvedOpenClawCommand {
|
||||
}
|
||||
|
||||
impl ResolvedOpenClawCommand {
|
||||
fn build_command(&self) -> Command {
|
||||
fn build_command_with_args<I, S>(&self, args: I) -> Command
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let command_path = self.command_path();
|
||||
let command_path_string = command_path.to_string_lossy().to_string();
|
||||
let mut command = Command::new(command_path);
|
||||
@@ -241,6 +246,8 @@ impl ResolvedOpenClawCommand {
|
||||
command.arg(cli_path);
|
||||
}
|
||||
|
||||
command.args(args);
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
@@ -1106,10 +1113,9 @@ impl OpenClawService {
|
||||
"info",
|
||||
);
|
||||
}
|
||||
let mut command = openclaw_command.build_command();
|
||||
let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token);
|
||||
let mut command = openclaw_command.build_command_with_args(&start_args);
|
||||
command
|
||||
.args(&start_args)
|
||||
.env(OPENCLAW_CONFIG_ENV, &config_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
@@ -1536,14 +1542,11 @@ impl OpenClawService {
|
||||
);
|
||||
}
|
||||
|
||||
let mut command = openclaw_command.build_command();
|
||||
let mut command = openclaw_command.build_command_with_args(["update", "--yes", "--json"]);
|
||||
if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) {
|
||||
command.current_dir(root);
|
||||
}
|
||||
let output = command
|
||||
.arg("update")
|
||||
.arg("--yes")
|
||||
.arg("--json")
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -1826,16 +1829,18 @@ impl OpenClawService {
|
||||
);
|
||||
}
|
||||
|
||||
let mut command = command_spec.build_command();
|
||||
let stop_args = vec![
|
||||
"gateway".to_string(),
|
||||
"stop".to_string(),
|
||||
"--url".to_string(),
|
||||
self.gateway_ws_url(),
|
||||
"--token".to_string(),
|
||||
self.gateway_auth_token.clone(),
|
||||
];
|
||||
let mut command = command_spec.build_command_with_args(&stop_args);
|
||||
let output = timeout(
|
||||
Duration::from_secs(8),
|
||||
command
|
||||
.arg("gateway")
|
||||
.arg("stop")
|
||||
.arg("--url")
|
||||
.arg(self.gateway_ws_url())
|
||||
.arg("--token")
|
||||
.arg(&self.gateway_auth_token)
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -1996,14 +2001,16 @@ impl OpenClawService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let mut command = command_spec.build_command();
|
||||
let status_args = vec![
|
||||
"gateway".to_string(),
|
||||
"status".to_string(),
|
||||
"--url".to_string(),
|
||||
self.gateway_ws_url(),
|
||||
"--token".to_string(),
|
||||
self.gateway_auth_token.clone(),
|
||||
];
|
||||
let mut command = command_spec.build_command_with_args(&status_args);
|
||||
let output = command
|
||||
.arg("gateway")
|
||||
.arg("status")
|
||||
.arg("--url")
|
||||
.arg(self.gateway_ws_url())
|
||||
.arg("--token")
|
||||
.arg(&self.gateway_auth_token)
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -2065,15 +2072,17 @@ impl OpenClawService {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut command = command_spec.build_command();
|
||||
let health_args = vec![
|
||||
"gateway".to_string(),
|
||||
"health".to_string(),
|
||||
"--url".to_string(),
|
||||
self.gateway_ws_url(),
|
||||
"--token".to_string(),
|
||||
self.gateway_auth_token.clone(),
|
||||
"--json".to_string(),
|
||||
];
|
||||
let mut command = command_spec.build_command_with_args(&health_args);
|
||||
let output = command
|
||||
.arg("gateway")
|
||||
.arg("health")
|
||||
.arg("--url")
|
||||
.arg(self.gateway_ws_url())
|
||||
.arg("--token")
|
||||
.arg(&self.gateway_auth_token)
|
||||
.arg("--json")
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -3269,11 +3278,8 @@ fn parse_openclaw_release_version(value: &str) -> Option<String> {
|
||||
async fn read_openclaw_update_status_payload(
|
||||
command_spec: &ResolvedOpenClawCommand,
|
||||
) -> Result<Value, String> {
|
||||
let mut command = command_spec.build_command();
|
||||
let mut command = command_spec.build_command_with_args(["update", "status", "--json"]);
|
||||
let output = command
|
||||
.arg("update")
|
||||
.arg("status")
|
||||
.arg("--json")
|
||||
.env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -3954,6 +3960,10 @@ fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String {
|
||||
core_shell_command_escape_for(platform, value)
|
||||
}
|
||||
|
||||
fn shell_command_invocation_prefix_for(platform: ShellPlatform, binary_path: &str) -> String {
|
||||
core_shell_command_invocation_prefix_for(platform, binary_path)
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
fn shell_command_escape(value: &str) -> String {
|
||||
shell_command_escape_for(current_shell_platform(), value)
|
||||
@@ -3998,8 +4008,9 @@ fn build_openclaw_pnpm_install_command(
|
||||
registry: Option<&str>,
|
||||
) -> String {
|
||||
let mut command = format!(
|
||||
"{}{} add -g {}",
|
||||
"{}{}{} add -g {}",
|
||||
shell_path_assignment_for(platform, pnpm_path),
|
||||
shell_command_invocation_prefix_for(platform, pnpm_path),
|
||||
shell_command_escape_for(platform, pnpm_path),
|
||||
shell_command_escape_for(platform, package),
|
||||
);
|
||||
@@ -4592,36 +4603,78 @@ fn resolve_openclaw_cli_entry_from_package_manifest(manifest_path: &Path) -> Opt
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
}
|
||||
|
||||
fn resolve_openclaw_command_from_runtime_candidate(
|
||||
fn prefers_node_cli_for_openclaw_path(platform: ShellPlatform, openclaw_path: &Path) -> bool {
|
||||
matches!(platform, ShellPlatform::Windows)
|
||||
&& openclaw_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn resolve_openclaw_command_from_runtime_candidate_for(
|
||||
platform: ShellPlatform,
|
||||
candidate: &OpenClawRuntimeCandidate,
|
||||
) -> Option<ResolvedOpenClawCommand> {
|
||||
if let Some(openclaw_path) = candidate
|
||||
let openclaw_path = candidate
|
||||
.openclaw_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.is_file())
|
||||
{
|
||||
return Some(ResolvedOpenClawCommand::Binary {
|
||||
binary_path: openclaw_path,
|
||||
});
|
||||
.filter(|path| path.is_file());
|
||||
|
||||
if let Some(openclaw_path) = openclaw_path.as_ref() {
|
||||
if !prefers_node_cli_for_openclaw_path(platform, openclaw_path) {
|
||||
return Some(ResolvedOpenClawCommand::Binary {
|
||||
binary_path: openclaw_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let node_path = PathBuf::from(candidate.node_path.as_str());
|
||||
if !node_path.is_file() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let manifest_path = candidate
|
||||
.openclaw_package_path
|
||||
.as_deref()
|
||||
.map(PathBuf::from)?;
|
||||
let cli_path = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path)?;
|
||||
.map(PathBuf::from);
|
||||
|
||||
Some(ResolvedOpenClawCommand::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
package_version: read_package_version(&manifest_path),
|
||||
})
|
||||
if node_path.is_file() {
|
||||
if let Some(manifest_path) = manifest_path {
|
||||
if let Some(cli_path) = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path)
|
||||
{
|
||||
return Some(ResolvedOpenClawCommand::NodeCli {
|
||||
node_path,
|
||||
cli_path,
|
||||
package_version: read_package_version(&manifest_path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openclaw_path.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path })
|
||||
}
|
||||
|
||||
fn resolve_openclaw_command_from_runtime_candidate(
|
||||
candidate: &OpenClawRuntimeCandidate,
|
||||
) -> Option<ResolvedOpenClawCommand> {
|
||||
resolve_openclaw_command_from_runtime_candidate_for(current_shell_platform(), candidate)
|
||||
}
|
||||
|
||||
fn runtime_candidate_matches_openclaw_path(
|
||||
candidate: &OpenClawRuntimeCandidate,
|
||||
openclaw_path: &Path,
|
||||
) -> bool {
|
||||
candidate
|
||||
.openclaw_path
|
||||
.as_deref()
|
||||
.map(Path::new)
|
||||
.is_some_and(|candidate_path| candidate_path == openclaw_path)
|
||||
|| openclaw_path
|
||||
.parent()
|
||||
.is_some_and(|parent| Path::new(&candidate.bin_dir) == parent)
|
||||
|| candidate
|
||||
.npm_global_prefix
|
||||
.as_deref()
|
||||
.map(Path::new)
|
||||
.is_some_and(|prefix| openclaw_path.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn dedupe_openclaw_commands(
|
||||
@@ -4649,17 +4702,36 @@ fn dedupe_paths(candidates: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
async fn resolve_openclaw_command() -> Result<Option<ResolvedOpenClawCommand>, String> {
|
||||
if let Some(binary) = find_command_in_shell("openclaw").await? {
|
||||
return Ok(Some(ResolvedOpenClawCommand::Binary {
|
||||
binary_path: PathBuf::from(binary),
|
||||
}));
|
||||
}
|
||||
|
||||
let shell_platform = current_shell_platform();
|
||||
let shell_binary = find_command_in_shell("openclaw").await?.map(PathBuf::from);
|
||||
let mut runtime_candidates = list_openclaw_runtime_candidates().await?;
|
||||
runtime_candidates.sort_by(compare_openclaw_runtime_candidates);
|
||||
Ok(runtime_candidates
|
||||
.iter()
|
||||
.find_map(resolve_openclaw_command_from_runtime_candidate))
|
||||
|
||||
if let Some(shell_binary) = shell_binary.as_ref() {
|
||||
if !prefers_node_cli_for_openclaw_path(shell_platform, shell_binary) {
|
||||
return Ok(Some(ResolvedOpenClawCommand::Binary {
|
||||
binary_path: shell_binary.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(command) = runtime_candidates
|
||||
.iter()
|
||||
.filter(|candidate| runtime_candidate_matches_openclaw_path(candidate, shell_binary))
|
||||
.find_map(|candidate| {
|
||||
resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate)
|
||||
})
|
||||
{
|
||||
return Ok(Some(command));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(command) = runtime_candidates.iter().find_map(|candidate| {
|
||||
resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate)
|
||||
}) {
|
||||
return Ok(Some(command));
|
||||
}
|
||||
|
||||
Ok(shell_binary.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path }))
|
||||
}
|
||||
|
||||
async fn read_openclaw_version_from_command(
|
||||
@@ -4670,8 +4742,7 @@ async fn read_openclaw_version_from_command(
|
||||
}
|
||||
|
||||
let output = command_spec
|
||||
.build_command()
|
||||
.arg("--version")
|
||||
.build_command_with_args(["--version"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
@@ -5345,16 +5416,19 @@ mod tests {
|
||||
infer_openclaw_package_name_from_path, npm_global_command_dirs_for,
|
||||
npm_global_node_modules_dirs_for, package_registry_for_package_spec,
|
||||
parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest,
|
||||
resolve_openclaw_command_from_runtime_candidate, resolve_windows_dependency_install_plan,
|
||||
runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_git_candidate,
|
||||
select_best_semver_candidate, select_gateway_start_failure_detail,
|
||||
select_openclaw_update_failure_detail, select_preferred_path_candidate,
|
||||
shell_command_escape_for, shell_npm_prefix_assignment_for, shell_path_assignment_for,
|
||||
trim_trailing_slash, windows_dependency_action_result, windows_dependency_setup_message,
|
||||
windows_git_install_dir_variants, windows_install_block_result,
|
||||
windows_manual_install_message, DependencyKind, DependencyStatus, EnvironmentDiagnostics,
|
||||
OpenClawRuntimeCandidate, ResolvedOpenClawCommand, ShellPlatform,
|
||||
WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
|
||||
resolve_openclaw_command_from_runtime_candidate,
|
||||
resolve_openclaw_command_from_runtime_candidate_for,
|
||||
resolve_windows_dependency_install_plan, runtime_candidate_matches_install_root,
|
||||
sanitize_runtime_config, select_best_git_candidate, select_best_semver_candidate,
|
||||
select_gateway_start_failure_detail, select_openclaw_update_failure_detail,
|
||||
select_preferred_path_candidate, shell_command_escape_for,
|
||||
shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for,
|
||||
shell_path_assignment_for, trim_trailing_slash, windows_dependency_action_result,
|
||||
windows_dependency_setup_message, windows_git_install_dir_variants,
|
||||
windows_install_block_result, windows_manual_install_message, DependencyKind,
|
||||
DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate,
|
||||
ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN,
|
||||
OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
|
||||
};
|
||||
use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup};
|
||||
use chrono::Utc;
|
||||
@@ -5854,6 +5928,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_cmd_scripts_use_call_invocation_prefix() {
|
||||
assert_eq!(
|
||||
shell_command_invocation_prefix_for(
|
||||
ShellPlatform::Windows,
|
||||
r"C:\Program Files\nodejs\npm.cmd"
|
||||
),
|
||||
"call "
|
||||
);
|
||||
assert!(shell_command_invocation_prefix_for(
|
||||
ShellPlatform::Windows,
|
||||
r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe"
|
||||
)
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() {
|
||||
let command = build_openclaw_cleanup_command(
|
||||
@@ -5867,7 +5957,7 @@ mod tests {
|
||||
concat!(
|
||||
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
|
||||
"set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ",
|
||||
"\"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh"
|
||||
"call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh"
|
||||
)
|
||||
);
|
||||
assert!(!command.contains("|| true"));
|
||||
@@ -5888,7 +5978,7 @@ mod tests {
|
||||
concat!(
|
||||
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
|
||||
"set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ",
|
||||
"\"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ",
|
||||
"call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ",
|
||||
"--registry=https://registry.npmmirror.com"
|
||||
)
|
||||
);
|
||||
@@ -5908,7 +5998,7 @@ mod tests {
|
||||
command,
|
||||
concat!(
|
||||
"set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ",
|
||||
"\"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest"
|
||||
"call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest"
|
||||
)
|
||||
);
|
||||
assert!(!command.contains("--registry="));
|
||||
@@ -5927,7 +6017,7 @@ mod tests {
|
||||
command,
|
||||
concat!(
|
||||
"set \"PATH=C:\\Users\\demo\\AppData\\Local\\pnpm;%PATH%\" && ",
|
||||
"\"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ",
|
||||
"call \"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ",
|
||||
"--registry=\"https://registry.npmmirror.com\""
|
||||
)
|
||||
);
|
||||
@@ -6176,6 +6266,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_runtime_candidate_prefers_node_cli_over_cmd_shim() {
|
||||
let temp_dir = build_unique_temp_dir("runtime-candidate-windows-shim");
|
||||
let node_bin_dir = temp_dir.join("nodejs");
|
||||
let package_dir = temp_dir
|
||||
.join("node_modules")
|
||||
.join("@qingchencloud")
|
||||
.join("openclaw-zh");
|
||||
let dist_dir = package_dir.join("dist");
|
||||
fs::create_dir_all(&node_bin_dir).unwrap();
|
||||
fs::create_dir_all(&dist_dir).unwrap();
|
||||
|
||||
let node_path = node_bin_dir.join("node.exe");
|
||||
let openclaw_cmd = node_bin_dir.join("openclaw.cmd");
|
||||
fs::write(&node_path, "").unwrap();
|
||||
fs::write(&openclaw_cmd, "@echo off").unwrap();
|
||||
fs::write(
|
||||
package_dir.join("package.json"),
|
||||
r#"{
|
||||
"name":"@qingchencloud/openclaw-zh",
|
||||
"version":"2026.3.13-zh.1",
|
||||
"bin":{"openclaw":"openclaw.mjs"}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap();
|
||||
|
||||
let candidate = OpenClawRuntimeCandidate {
|
||||
id: temp_dir.display().to_string(),
|
||||
source: "system".to_string(),
|
||||
bin_dir: node_bin_dir.display().to_string(),
|
||||
node_path: node_path.display().to_string(),
|
||||
node_version: Some("23.4.0".to_string()),
|
||||
npm_path: Some(node_bin_dir.join("npm.cmd").display().to_string()),
|
||||
npm_global_prefix: Some(temp_dir.display().to_string()),
|
||||
openclaw_path: Some(openclaw_cmd.display().to_string()),
|
||||
openclaw_version: Some("2026.3.13-zh.1".to_string()),
|
||||
openclaw_package_path: Some(package_dir.join("package.json").display().to_string()),
|
||||
is_active: true,
|
||||
is_preferred: true,
|
||||
};
|
||||
|
||||
let resolved =
|
||||
resolve_openclaw_command_from_runtime_candidate_for(ShellPlatform::Windows, &candidate);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
Some(ResolvedOpenClawCommand::NodeCli {
|
||||
node_path,
|
||||
cli_path: package_dir.join("dist").join("index.js"),
|
||||
package_version: Some("2026.3.13-zh.1".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_node_prefers_winget_when_available() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! 运行时 AGENTS 模板生成服务
|
||||
//!
|
||||
//! 为 Lime 应用运行时会话显式生成 `.lime/AGENTS.md` 模板。
|
||||
|
||||
use lime_core::app_paths;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeAgentsTemplateTarget {
|
||||
Global,
|
||||
Workspace,
|
||||
WorkspaceLocal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeAgentsTemplateScaffoldStatus {
|
||||
Created,
|
||||
Exists,
|
||||
Overwritten,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceGitignoreEnsureStatus {
|
||||
Created,
|
||||
Added,
|
||||
Exists,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeAgentsTemplateScaffoldResult {
|
||||
pub target: RuntimeAgentsTemplateTarget,
|
||||
pub path: String,
|
||||
pub status: RuntimeAgentsTemplateScaffoldStatus,
|
||||
pub created_parent_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkspaceGitignoreEnsureResult {
|
||||
pub path: String,
|
||||
pub entry: String,
|
||||
pub status: WorkspaceGitignoreEnsureStatus,
|
||||
}
|
||||
|
||||
pub fn scaffold_runtime_agents_template(
|
||||
target: RuntimeAgentsTemplateTarget,
|
||||
working_dir: Option<&Path>,
|
||||
overwrite: bool,
|
||||
) -> Result<RuntimeAgentsTemplateScaffoldResult, String> {
|
||||
let path = resolve_runtime_agents_template_path(target, working_dir)?;
|
||||
scaffold_runtime_agents_template_at_path(target, &path, overwrite)
|
||||
}
|
||||
|
||||
pub fn ensure_workspace_local_agents_gitignore(
|
||||
working_dir: &Path,
|
||||
) -> Result<WorkspaceGitignoreEnsureResult, String> {
|
||||
let gitignore_path = working_dir.join(".gitignore");
|
||||
ensure_gitignore_entry(&gitignore_path, ".lime/AGENTS.local.md")
|
||||
}
|
||||
|
||||
fn resolve_runtime_agents_template_path(
|
||||
target: RuntimeAgentsTemplateTarget,
|
||||
working_dir: Option<&Path>,
|
||||
) -> Result<PathBuf, String> {
|
||||
match target {
|
||||
RuntimeAgentsTemplateTarget::Global => Ok(app_paths::best_effort_user_memory_path()),
|
||||
RuntimeAgentsTemplateTarget::Workspace => {
|
||||
let working_dir =
|
||||
working_dir.ok_or_else(|| "生成 Workspace 模板时缺少 working_dir".to_string())?;
|
||||
Ok(working_dir.join(".lime").join("AGENTS.md"))
|
||||
}
|
||||
RuntimeAgentsTemplateTarget::WorkspaceLocal => {
|
||||
let working_dir = working_dir
|
||||
.ok_or_else(|| "生成 Workspace 本机模板时缺少 working_dir".to_string())?;
|
||||
Ok(working_dir.join(".lime").join("AGENTS.local.md"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scaffold_runtime_agents_template_at_path(
|
||||
target: RuntimeAgentsTemplateTarget,
|
||||
path: &Path,
|
||||
overwrite: bool,
|
||||
) -> Result<RuntimeAgentsTemplateScaffoldResult, String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("无法解析模板目录: {}", path.display()))?;
|
||||
let created_parent_dir = !parent.exists();
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("创建模板目录失败 {}: {e}", parent.display()))?;
|
||||
|
||||
if path.exists() && path.is_dir() {
|
||||
return Err(format!("模板路径指向目录而不是文件: {}", path.display()));
|
||||
}
|
||||
|
||||
let existed = path.exists();
|
||||
if existed && !overwrite {
|
||||
return Ok(RuntimeAgentsTemplateScaffoldResult {
|
||||
target,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
status: RuntimeAgentsTemplateScaffoldStatus::Exists,
|
||||
created_parent_dir,
|
||||
});
|
||||
}
|
||||
|
||||
fs::write(path, template_content_for_target(target))
|
||||
.map_err(|e| format!("写入模板失败 {}: {e}", path.display()))?;
|
||||
|
||||
Ok(RuntimeAgentsTemplateScaffoldResult {
|
||||
target,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
status: if existed {
|
||||
RuntimeAgentsTemplateScaffoldStatus::Overwritten
|
||||
} else {
|
||||
RuntimeAgentsTemplateScaffoldStatus::Created
|
||||
},
|
||||
created_parent_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_gitignore_entry(
|
||||
gitignore_path: &Path,
|
||||
entry: &str,
|
||||
) -> Result<WorkspaceGitignoreEnsureResult, String> {
|
||||
if gitignore_path.exists() && gitignore_path.is_dir() {
|
||||
return Err(format!(
|
||||
".gitignore 路径指向目录而不是文件: {}",
|
||||
gitignore_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
return Err("gitignore 条目不能为空".to_string());
|
||||
}
|
||||
|
||||
if !gitignore_path.exists() {
|
||||
fs::write(gitignore_path, format!("{entry}\n"))
|
||||
.map_err(|e| format!("创建 .gitignore 失败 {}: {e}", gitignore_path.display()))?;
|
||||
return Ok(WorkspaceGitignoreEnsureResult {
|
||||
path: gitignore_path.to_string_lossy().to_string(),
|
||||
entry: entry.to_string(),
|
||||
status: WorkspaceGitignoreEnsureStatus::Created,
|
||||
});
|
||||
}
|
||||
|
||||
let existing = fs::read_to_string(gitignore_path)
|
||||
.map_err(|e| format!("读取 .gitignore 失败 {}: {e}", gitignore_path.display()))?;
|
||||
if existing.lines().any(|line| line.trim() == entry) {
|
||||
return Ok(WorkspaceGitignoreEnsureResult {
|
||||
path: gitignore_path.to_string_lossy().to_string(),
|
||||
entry: entry.to_string(),
|
||||
status: WorkspaceGitignoreEnsureStatus::Exists,
|
||||
});
|
||||
}
|
||||
|
||||
let mut next = existing;
|
||||
if !next.is_empty() && !next.ends_with('\n') {
|
||||
next.push('\n');
|
||||
}
|
||||
next.push_str(entry);
|
||||
next.push('\n');
|
||||
fs::write(gitignore_path, next)
|
||||
.map_err(|e| format!("更新 .gitignore 失败 {}: {e}", gitignore_path.display()))?;
|
||||
|
||||
Ok(WorkspaceGitignoreEnsureResult {
|
||||
path: gitignore_path.to_string_lossy().to_string(),
|
||||
entry: entry.to_string(),
|
||||
status: WorkspaceGitignoreEnsureStatus::Added,
|
||||
})
|
||||
}
|
||||
|
||||
fn template_content_for_target(target: RuntimeAgentsTemplateTarget) -> &'static str {
|
||||
match target {
|
||||
RuntimeAgentsTemplateTarget::Global => GLOBAL_RUNTIME_AGENTS_TEMPLATE,
|
||||
RuntimeAgentsTemplateTarget::Workspace => WORKSPACE_RUNTIME_AGENTS_TEMPLATE,
|
||||
RuntimeAgentsTemplateTarget::WorkspaceLocal => WORKSPACE_LOCAL_RUNTIME_AGENTS_TEMPLATE,
|
||||
}
|
||||
}
|
||||
|
||||
const GLOBAL_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 我的全局 Lime 运行时规则
|
||||
|
||||
## 回复习惯
|
||||
|
||||
- 默认使用中文简体
|
||||
- 先给结论,再给关键步骤
|
||||
- 没必要时保持简洁,不要过度展开
|
||||
|
||||
## 工程偏好
|
||||
|
||||
- 优先选择 KISS 方案
|
||||
- 优先修根因,不做表面补丁
|
||||
- 先说明影响范围,再做改动
|
||||
|
||||
## 代码风格
|
||||
|
||||
- 尽量沿用现有项目风格
|
||||
- 避免无关重构
|
||||
- 没有明确收益时,不新增抽象层
|
||||
"#;
|
||||
|
||||
const WORKSPACE_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 当前工作区运行时规则
|
||||
|
||||
## 项目背景
|
||||
|
||||
- 这里填写当前项目的技术栈与上下文
|
||||
- 这里填写默认输出语言或文档语言
|
||||
|
||||
## 修改原则
|
||||
|
||||
- 先读后写
|
||||
- 只改当前任务直接相关内容
|
||||
- 保持现有目录结构和命名习惯
|
||||
|
||||
## 验证要求
|
||||
|
||||
- 前端改动后优先跑相关前端测试
|
||||
- Rust 改动后优先跑相关单测
|
||||
- 若无法完整验证,需要明确说明未验证部分
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 不要提交临时排障脚本
|
||||
- 不要修改无关配置
|
||||
- 不要默认执行 git commit 或 push
|
||||
"#;
|
||||
|
||||
const WORKSPACE_LOCAL_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 本机私有补充
|
||||
|
||||
## 本机偏好
|
||||
|
||||
- 优先使用本机已安装的工具链
|
||||
- 涉及大体量编译时,先跑定向测试
|
||||
|
||||
## 私有约束
|
||||
|
||||
- 这里填写只在当前机器生效的补充规则
|
||||
- 如不希望提交到仓库,请将本文件加入 .gitignore
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn should_create_workspace_template_file() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let path = tmp.path().join(".lime").join("AGENTS.md");
|
||||
|
||||
let result = scaffold_runtime_agents_template_at_path(
|
||||
RuntimeAgentsTemplateTarget::Workspace,
|
||||
&path,
|
||||
false,
|
||||
)
|
||||
.expect("scaffold should succeed");
|
||||
|
||||
assert_eq!(result.status, RuntimeAgentsTemplateScaffoldStatus::Created);
|
||||
assert!(path.exists());
|
||||
let content = fs::read_to_string(&path).expect("read template");
|
||||
assert!(content.contains("当前工作区运行时规则"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_overwrite_existing_file_by_default() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let path = tmp.path().join(".lime").join("AGENTS.md");
|
||||
fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
|
||||
fs::write(&path, "custom content").expect("write custom");
|
||||
|
||||
let result = scaffold_runtime_agents_template_at_path(
|
||||
RuntimeAgentsTemplateTarget::Workspace,
|
||||
&path,
|
||||
false,
|
||||
)
|
||||
.expect("scaffold should succeed");
|
||||
|
||||
assert_eq!(result.status, RuntimeAgentsTemplateScaffoldStatus::Exists);
|
||||
let content = fs::read_to_string(&path).expect("read file");
|
||||
assert_eq!(content, "custom content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_overwrite_existing_file_when_requested() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let path = tmp.path().join(".lime").join("AGENTS.local.md");
|
||||
fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
|
||||
fs::write(&path, "custom content").expect("write custom");
|
||||
|
||||
let result = scaffold_runtime_agents_template_at_path(
|
||||
RuntimeAgentsTemplateTarget::WorkspaceLocal,
|
||||
&path,
|
||||
true,
|
||||
)
|
||||
.expect("scaffold should succeed");
|
||||
|
||||
assert_eq!(
|
||||
result.status,
|
||||
RuntimeAgentsTemplateScaffoldStatus::Overwritten
|
||||
);
|
||||
let content = fs::read_to_string(&path).expect("read file");
|
||||
assert!(content.contains("本机私有补充"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_create_gitignore_when_missing() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
|
||||
let result = ensure_workspace_local_agents_gitignore(tmp.path())
|
||||
.expect("ensure gitignore should succeed");
|
||||
|
||||
assert_eq!(result.status, WorkspaceGitignoreEnsureStatus::Created);
|
||||
let content = fs::read_to_string(tmp.path().join(".gitignore")).expect("read gitignore");
|
||||
assert_eq!(content, ".lime/AGENTS.local.md\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_append_gitignore_entry_once() {
|
||||
let tmp = TempDir::new().expect("create temp dir");
|
||||
let gitignore_path = tmp.path().join(".gitignore");
|
||||
fs::write(&gitignore_path, "node_modules\n").expect("write gitignore");
|
||||
|
||||
let first = ensure_workspace_local_agents_gitignore(tmp.path())
|
||||
.expect("first ensure should succeed");
|
||||
let second = ensure_workspace_local_agents_gitignore(tmp.path())
|
||||
.expect("second ensure should succeed");
|
||||
|
||||
assert_eq!(first.status, WorkspaceGitignoreEnsureStatus::Added);
|
||||
assert_eq!(second.status, WorkspaceGitignoreEnsureStatus::Exists);
|
||||
let content = fs::read_to_string(gitignore_path).expect("read gitignore");
|
||||
assert_eq!(content.matches(".lime/AGENTS.local.md").count(), 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user