release: v0.93.0

This commit is contained in:
coso
2026-03-21 23:10:41 +08:00
parent 43e79847b2
commit dc37baff36
193 changed files with 30459 additions and 13754 deletions
+43 -5
View File
@@ -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
View File
@@ -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(&current_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(&current_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, &params, session_hint.as_ref());
let profile_key = Self::extract_profile_key(&params, _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, &params)
.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(&params, "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(&params, "model", Some(SOCIAL_IMAGE_DEFAULT_MODEL))
.unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_MODEL.to_string());
let size = Self::parse_non_empty_string(
&params,
"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(
&params,
"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(), &params)?;
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(&params);
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(&params);
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
}
-686
View File
@@ -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")
}
+1
View File
@@ -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;
+12 -2
View File
@@ -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();
+6 -3
View File
@@ -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)),
),
+475 -61
View File
@@ -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(&current_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"));
}
}
+2
View File
@@ -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;
+224 -77
View File
@@ -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);
}
}