refactor(desktop): split kernel_commands/pipeline_commands into modules, add SaaS client libs and gateway modules
Split monolithic kernel_commands.rs (2185 lines) and pipeline_commands.rs (1391 lines) into focused sub-modules under kernel_commands/ and pipeline_commands/ directories. Add gateway module (commands, config, io, runtime), health_check, and 15 new TypeScript client libraries for SaaS relay, auth, admin, telemetry, and kernel sub-systems (a2a, agent, chat, hands, skills, triggers). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
114
desktop/src-tauri/src/kernel_commands/a2a.rs
Normal file
114
desktop/src-tauri/src/kernel_commands/a2a.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! A2A (Agent-to-Agent) commands — gated behind `multi-agent` feature
|
||||
|
||||
use serde_json;
|
||||
use tauri::State;
|
||||
use zclaw_types::AgentId;
|
||||
|
||||
use super::KernelState;
|
||||
|
||||
// ============================================================
|
||||
// A2A (Agent-to-Agent) Commands — gated behind multi-agent feature
|
||||
// ============================================================
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
/// Send a direct A2A message from one agent to another
|
||||
#[tauri::command]
|
||||
pub async fn agent_a2a_send(
|
||||
state: State<'_, KernelState>,
|
||||
from: String,
|
||||
to: String,
|
||||
payload: serde_json::Value,
|
||||
message_type: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let from_id: AgentId = from.parse()
|
||||
.map_err(|_| format!("Invalid from agent ID: {}", from))?;
|
||||
let to_id: AgentId = to.parse()
|
||||
.map_err(|_| format!("Invalid to agent ID: {}", to))?;
|
||||
|
||||
let msg_type = message_type.map(|mt| match mt.as_str() {
|
||||
"request" => zclaw_kernel::A2aMessageType::Request,
|
||||
"notification" => zclaw_kernel::A2aMessageType::Notification,
|
||||
"task" => zclaw_kernel::A2aMessageType::Task,
|
||||
_ => zclaw_kernel::A2aMessageType::Notification,
|
||||
});
|
||||
|
||||
kernel.a2a_send(&from_id, &to_id, payload, msg_type).await
|
||||
.map_err(|e| format!("A2A send failed: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast a message from one agent to all other agents
|
||||
#[cfg(feature = "multi-agent")]
|
||||
#[tauri::command]
|
||||
pub async fn agent_a2a_broadcast(
|
||||
state: State<'_, KernelState>,
|
||||
from: String,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let from_id: AgentId = from.parse()
|
||||
.map_err(|_| format!("Invalid from agent ID: {}", from))?;
|
||||
|
||||
kernel.a2a_broadcast(&from_id, payload).await
|
||||
.map_err(|e| format!("A2A broadcast failed: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover agents with a specific capability
|
||||
#[cfg(feature = "multi-agent")]
|
||||
#[tauri::command]
|
||||
pub async fn agent_a2a_discover(
|
||||
state: State<'_, KernelState>,
|
||||
capability: String,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let profiles = kernel.a2a_discover(&capability).await
|
||||
.map_err(|e| format!("A2A discover failed: {}", e))?;
|
||||
|
||||
let result: Vec<serde_json::Value> = profiles.iter()
|
||||
.filter_map(|p| serde_json::to_value(p).ok())
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Delegate a task to another agent and wait for response
|
||||
#[cfg(feature = "multi-agent")]
|
||||
#[tauri::command]
|
||||
pub async fn agent_a2a_delegate_task(
|
||||
state: State<'_, KernelState>,
|
||||
from: String,
|
||||
to: String,
|
||||
task: String,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let from_id: AgentId = from.parse()
|
||||
.map_err(|_| format!("Invalid from agent ID: {}", from))?;
|
||||
let to_id: AgentId = to.parse()
|
||||
.map_err(|_| format!("Invalid to agent ID: {}", to))?;
|
||||
|
||||
let timeout = timeout_ms.unwrap_or(30_000);
|
||||
|
||||
// 30 seconds default
|
||||
|
||||
let response = kernel.a2a_delegate_task(&from_id, &to_id, task, timeout).await
|
||||
.map_err(|e| format!("A2A task delegation failed: {}", e))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
257
desktop/src-tauri/src/kernel_commands/agent.rs
Normal file
257
desktop/src-tauri/src/kernel_commands/agent.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! Agent CRUD commands: create, list, get, delete, update, export, import
|
||||
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo};
|
||||
|
||||
use super::{validate_agent_id, KernelState};
|
||||
use crate::intelligence::validation::validate_string_length;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn default_provider() -> String { "openai".to_string() }
|
||||
fn default_model() -> String { "gpt-4o-mini".to_string() }
|
||||
fn default_max_tokens() -> u32 { 4096 }
|
||||
fn default_temperature() -> f32 { 0.7 }
|
||||
|
||||
/// Agent creation request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAgentRequest {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default = "default_provider")]
|
||||
pub provider: String,
|
||||
#[serde(default = "default_model")]
|
||||
pub model: String,
|
||||
#[serde(default = "default_max_tokens")]
|
||||
pub max_tokens: u32,
|
||||
#[serde(default = "default_temperature")]
|
||||
pub temperature: f32,
|
||||
#[serde(default)]
|
||||
pub workspace: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Agent creation response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAgentResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// Agent update request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentUpdateRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a new agent
|
||||
#[tauri::command]
|
||||
pub async fn agent_create(
|
||||
state: State<'_, KernelState>,
|
||||
request: CreateAgentRequest,
|
||||
) -> Result<CreateAgentResponse, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let mut config = AgentConfig::new(&request.name)
|
||||
.with_description(request.description.unwrap_or_default())
|
||||
.with_system_prompt(request.system_prompt.unwrap_or_default())
|
||||
.with_model(zclaw_types::ModelConfig {
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
api_key_env: None,
|
||||
base_url: None,
|
||||
})
|
||||
.with_max_tokens(request.max_tokens)
|
||||
.with_temperature(request.temperature);
|
||||
|
||||
if let Some(workspace) = request.workspace {
|
||||
config.workspace = Some(workspace);
|
||||
}
|
||||
|
||||
let id = kernel.spawn_agent(config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create agent: {}", e))?;
|
||||
|
||||
Ok(CreateAgentResponse {
|
||||
id: id.to_string(),
|
||||
name: request.name,
|
||||
state: "running".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// List all agents
|
||||
#[tauri::command]
|
||||
pub async fn agent_list(
|
||||
state: State<'_, KernelState>,
|
||||
) -> Result<Vec<AgentInfo>, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
Ok(kernel.list_agents())
|
||||
}
|
||||
|
||||
/// Get agent info
|
||||
#[tauri::command]
|
||||
pub async fn agent_get(
|
||||
state: State<'_, KernelState>,
|
||||
agent_id: String,
|
||||
) -> Result<Option<AgentInfo>, String> {
|
||||
let agent_id = validate_agent_id(&agent_id)?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let id: AgentId = agent_id.parse()
|
||||
.map_err(|_| "Invalid agent ID format".to_string())?;
|
||||
|
||||
Ok(kernel.get_agent(&id))
|
||||
}
|
||||
|
||||
/// Delete an agent
|
||||
#[tauri::command]
|
||||
pub async fn agent_delete(
|
||||
state: State<'_, KernelState>,
|
||||
agent_id: String,
|
||||
) -> Result<(), String> {
|
||||
let agent_id = validate_agent_id(&agent_id)?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let id: AgentId = agent_id.parse()
|
||||
.map_err(|_| "Invalid agent ID format".to_string())?;
|
||||
|
||||
kernel.kill_agent(&id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete agent: {}", e))
|
||||
}
|
||||
|
||||
/// Update an agent's configuration
|
||||
#[tauri::command]
|
||||
pub async fn agent_update(
|
||||
state: State<'_, KernelState>,
|
||||
agent_id: String,
|
||||
updates: AgentUpdateRequest,
|
||||
) -> Result<AgentInfo, String> {
|
||||
let agent_id = validate_agent_id(&agent_id)?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let id: AgentId = agent_id.parse()
|
||||
.map_err(|_| "Invalid agent ID format".to_string())?;
|
||||
|
||||
// Get existing config
|
||||
let mut config = kernel.get_agent_config(&id)
|
||||
.ok_or_else(|| format!("Agent not found: {}", agent_id))?;
|
||||
|
||||
// Apply updates
|
||||
if let Some(name) = updates.name {
|
||||
config.name = name;
|
||||
}
|
||||
if let Some(description) = updates.description {
|
||||
config.description = Some(description);
|
||||
}
|
||||
if let Some(system_prompt) = updates.system_prompt {
|
||||
config.system_prompt = Some(system_prompt);
|
||||
}
|
||||
if let Some(model) = updates.model {
|
||||
config.model.model = model;
|
||||
}
|
||||
if let Some(provider) = updates.provider {
|
||||
config.model.provider = provider;
|
||||
}
|
||||
if let Some(max_tokens) = updates.max_tokens {
|
||||
config.max_tokens = Some(max_tokens);
|
||||
}
|
||||
if let Some(temperature) = updates.temperature {
|
||||
config.temperature = Some(temperature);
|
||||
}
|
||||
|
||||
// Save updated config
|
||||
kernel.update_agent(config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update agent: {}", e))?;
|
||||
|
||||
// Return updated info
|
||||
kernel.get_agent(&id)
|
||||
.ok_or_else(|| format!("Agent not found after update: {}", agent_id))
|
||||
}
|
||||
|
||||
/// Export an agent configuration as JSON
|
||||
#[tauri::command]
|
||||
pub async fn agent_export(
|
||||
state: State<'_, KernelState>,
|
||||
agent_id: String,
|
||||
) -> Result<String, String> {
|
||||
let agent_id = validate_agent_id(&agent_id)?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let id: AgentId = agent_id.parse()
|
||||
.map_err(|_| "Invalid agent ID format".to_string())?;
|
||||
|
||||
let config = kernel.get_agent_config(&id)
|
||||
.ok_or_else(|| format!("Agent not found: {}", agent_id))?;
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize agent config: {}", e))
|
||||
}
|
||||
|
||||
/// Import an agent from JSON configuration
|
||||
#[tauri::command]
|
||||
pub async fn agent_import(
|
||||
state: State<'_, KernelState>,
|
||||
config_json: String,
|
||||
) -> Result<AgentInfo, String> {
|
||||
validate_string_length(&config_json, "config_json", 1_000_000)
|
||||
.map_err(|e| format!("{}", e))?;
|
||||
|
||||
let mut config: AgentConfig = serde_json::from_str(&config_json)
|
||||
.map_err(|e| format!("Invalid agent config JSON: {}", e))?;
|
||||
|
||||
// Regenerate ID to avoid collisions
|
||||
config.id = AgentId::new();
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let new_id = kernel.spawn_agent(config).await
|
||||
.map_err(|e| format!("Failed to import agent: {}", e))?;
|
||||
|
||||
kernel.get_agent(&new_id)
|
||||
.ok_or_else(|| "Agent was created but could not be retrieved".to_string())
|
||||
}
|
||||
140
desktop/src-tauri/src/kernel_commands/approval.rs
Normal file
140
desktop/src-tauri/src/kernel_commands/approval.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! Approval commands: list and respond
|
||||
//!
|
||||
//! When approved, kernel's `respond_to_approval` internally spawns the Hand execution
|
||||
//! and emits `hand-execution-complete` events to the frontend.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::KernelState;
|
||||
|
||||
// ============================================================
|
||||
// Approval Commands
|
||||
// ============================================================
|
||||
|
||||
/// Approval response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApprovalResponse {
|
||||
pub id: String,
|
||||
pub hand_id: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub input: serde_json::Value,
|
||||
}
|
||||
|
||||
/// List pending approvals
|
||||
#[tauri::command]
|
||||
pub async fn approval_list(
|
||||
state: State<'_, KernelState>,
|
||||
) -> Result<Vec<ApprovalResponse>, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let approvals = kernel.list_approvals().await;
|
||||
Ok(approvals.into_iter().map(|a| ApprovalResponse {
|
||||
id: a.id,
|
||||
hand_id: a.hand_id,
|
||||
status: a.status,
|
||||
created_at: a.created_at.to_rfc3339(),
|
||||
input: a.input,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// Respond to an approval
|
||||
///
|
||||
/// When approved, the kernel's `respond_to_approval` internally spawns the Hand
|
||||
/// execution. We additionally emit Tauri events so the frontend can track when
|
||||
/// the execution finishes, since the kernel layer has no access to the AppHandle.
|
||||
#[tauri::command]
|
||||
pub async fn approval_respond(
|
||||
app: AppHandle,
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
approved: bool,
|
||||
reason: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// Capture hand info before calling respond_to_approval (which mutates the approval)
|
||||
let hand_id = {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let approvals = kernel.list_approvals().await;
|
||||
let entry = approvals.iter().find(|a| a.id == id && a.status == "pending")
|
||||
.ok_or_else(|| format!("Approval not found or already resolved: {}", id))?;
|
||||
entry.hand_id.clone()
|
||||
};
|
||||
|
||||
// Call kernel respond_to_approval (this updates status and spawns Hand execution)
|
||||
{
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
kernel.respond_to_approval(&id, approved, reason).await
|
||||
.map_err(|e| format!("Failed to respond to approval: {}", e))?;
|
||||
}
|
||||
|
||||
// When approved, monitor the Hand execution and emit events to the frontend.
|
||||
// The kernel's respond_to_approval changes status to "approved" immediately,
|
||||
// then the spawned task sets it to "completed" or "failed" when done.
|
||||
if approved {
|
||||
let approval_id = id.clone();
|
||||
let kernel_state: KernelState = (*state).clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let timeout = tokio::time::Duration::from_secs(300);
|
||||
let poll_interval = tokio::time::Duration::from_millis(500);
|
||||
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
let kernel_lock = kernel_state.lock().await;
|
||||
if let Some(kernel) = kernel_lock.as_ref() {
|
||||
// Use get_approval to check any status (not just "pending")
|
||||
if let Some(entry) = kernel.get_approval(&approval_id).await {
|
||||
match entry.status.as_str() {
|
||||
"completed" => {
|
||||
tracing::info!("[approval_respond] Hand '{}' completed for approval {}", hand_id, approval_id);
|
||||
return (true, None::<String>);
|
||||
}
|
||||
"failed" => {
|
||||
let error_msg = entry.input.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string();
|
||||
tracing::warn!("[approval_respond] Hand '{}' failed for approval {}: {}", hand_id, approval_id, error_msg);
|
||||
return (false, Some(error_msg));
|
||||
}
|
||||
_ => {} // "approved" = still running
|
||||
}
|
||||
} else {
|
||||
// Entry disappeared entirely — kernel was likely restarted
|
||||
return (false, Some("Approval entry disappeared".to_string()));
|
||||
}
|
||||
} else {
|
||||
return (false, Some("Kernel not available".to_string()));
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
|
||||
let (success, error) = match result {
|
||||
Ok((s, e)) => (s, e),
|
||||
Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())),
|
||||
};
|
||||
|
||||
let _ = app.emit("hand-execution-complete", serde_json::json!({
|
||||
"approvalId": approval_id,
|
||||
"handId": hand_id,
|
||||
"success": success,
|
||||
"error": error,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
274
desktop/src-tauri/src/kernel_commands/chat.rs
Normal file
274
desktop/src-tauri/src/kernel_commands/chat.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
//! Chat commands: send message, streaming chat
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::Mutex;
|
||||
use zclaw_types::AgentId;
|
||||
|
||||
use super::{validate_agent_id, KernelState, SessionStreamGuard};
|
||||
use crate::intelligence::validation::validate_string_length;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Chat request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatRequest {
|
||||
pub agent_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Chat response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChatResponse {
|
||||
pub content: String,
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
|
||||
/// Streaming chat event for Tauri emission
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub enum StreamChatEvent {
|
||||
Delta { delta: String },
|
||||
ToolStart { name: String, input: serde_json::Value },
|
||||
ToolEnd { name: String, output: serde_json::Value },
|
||||
IterationStart { iteration: usize, max_iterations: usize },
|
||||
HandStart { name: String, params: serde_json::Value },
|
||||
HandEnd { name: String, result: serde_json::Value },
|
||||
Complete { input_tokens: u32, output_tokens: u32 },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// Streaming chat request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamChatRequest {
|
||||
pub agent_id: String,
|
||||
pub session_id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Send a message to an agent
|
||||
#[tauri::command]
|
||||
pub async fn agent_chat(
|
||||
state: State<'_, KernelState>,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse, String> {
|
||||
validate_agent_id(&request.agent_id)?;
|
||||
validate_string_length(&request.message, "message", 100000)
|
||||
.map_err(|e| format!("Invalid message: {}", e))?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let id: AgentId = request.agent_id.parse()
|
||||
.map_err(|_| "Invalid agent ID format".to_string())?;
|
||||
|
||||
let response = kernel.send_message(&id, request.message)
|
||||
.await
|
||||
.map_err(|e| format!("Chat failed: {}", e))?;
|
||||
|
||||
Ok(ChatResponse {
|
||||
content: response.content,
|
||||
input_tokens: response.input_tokens,
|
||||
output_tokens: response.output_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a message to an agent with streaming response
|
||||
///
|
||||
/// This command initiates a streaming chat session. Events are emitted
|
||||
/// via Tauri's event system with the name "stream:chunk" and include
|
||||
/// the session_id for routing.
|
||||
#[tauri::command]
|
||||
pub async fn agent_chat_stream(
|
||||
app: AppHandle,
|
||||
state: State<'_, KernelState>,
|
||||
identity_state: State<'_, crate::intelligence::IdentityManagerState>,
|
||||
heartbeat_state: State<'_, crate::intelligence::HeartbeatEngineState>,
|
||||
reflection_state: State<'_, crate::intelligence::ReflectionEngineState>,
|
||||
stream_guard: State<'_, SessionStreamGuard>,
|
||||
request: StreamChatRequest,
|
||||
) -> Result<(), String> {
|
||||
validate_agent_id(&request.agent_id)?;
|
||||
validate_string_length(&request.message, "message", 100000)
|
||||
.map_err(|e| format!("Invalid message: {}", e))?;
|
||||
|
||||
let id: AgentId = request.agent_id.parse()
|
||||
.map_err(|_| "Invalid agent ID format".to_string())?;
|
||||
|
||||
let session_id = request.session_id.clone();
|
||||
let agent_id_str = request.agent_id.clone();
|
||||
let message = request.message.clone();
|
||||
|
||||
// Session-level concurrency guard
|
||||
let session_mutex = stream_guard
|
||||
.entry(session_id.clone())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())));
|
||||
let _session_guard = session_mutex.try_lock()
|
||||
.map_err(|_| {
|
||||
tracing::warn!(
|
||||
"[agent_chat_stream] Session {} already has an active stream — rejecting",
|
||||
session_id
|
||||
);
|
||||
format!("Session {} already has an active stream", session_id)
|
||||
})?;
|
||||
|
||||
// AUTO-INIT HEARTBEAT
|
||||
{
|
||||
let mut engines = heartbeat_state.lock().await;
|
||||
if !engines.contains_key(&request.agent_id) {
|
||||
let engine = crate::intelligence::heartbeat::HeartbeatEngine::new(
|
||||
request.agent_id.clone(),
|
||||
None,
|
||||
);
|
||||
engines.insert(request.agent_id.clone(), engine);
|
||||
tracing::info!("[agent_chat_stream] Auto-initialized heartbeat for agent: {}", request.agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
// PRE-CONVERSATION: Build intelligence-enhanced system prompt
|
||||
let enhanced_prompt = crate::intelligence_hooks::pre_conversation_hook(
|
||||
&request.agent_id,
|
||||
&request.message,
|
||||
&identity_state,
|
||||
).await.unwrap_or_default();
|
||||
|
||||
// Get the streaming receiver while holding the lock, then release it
|
||||
let (mut rx, llm_driver) = {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let driver = Some(kernel.driver());
|
||||
|
||||
let prompt_arg = if enhanced_prompt.is_empty() { None } else { Some(enhanced_prompt) };
|
||||
|
||||
let session_id_parsed = if session_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match uuid::Uuid::parse_str(&session_id) {
|
||||
Ok(uuid) => Some(zclaw_types::SessionId::from_uuid(uuid)),
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Invalid session_id '{}': {}. Cannot reuse conversation context.",
|
||||
session_id, e
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
let rx = kernel.send_message_stream_with_prompt(&id, message.clone(), prompt_arg, session_id_parsed)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start streaming: {}", e))?;
|
||||
(rx, driver)
|
||||
};
|
||||
|
||||
let hb_state = heartbeat_state.inner().clone();
|
||||
let rf_state = reflection_state.inner().clone();
|
||||
|
||||
// Spawn a task to process stream events with timeout guard
|
||||
tokio::spawn(async move {
|
||||
use zclaw_runtime::LoopEvent;
|
||||
|
||||
tracing::debug!("[agent_chat_stream] Starting stream processing for session: {}", session_id);
|
||||
|
||||
let stream_timeout = tokio::time::Duration::from_secs(300);
|
||||
|
||||
loop {
|
||||
match tokio::time::timeout(stream_timeout, rx.recv()).await {
|
||||
Ok(Some(event)) => {
|
||||
let stream_event = match &event {
|
||||
LoopEvent::Delta(delta) => {
|
||||
tracing::trace!("[agent_chat_stream] Delta: {} bytes", delta.len());
|
||||
StreamChatEvent::Delta { delta: delta.clone() }
|
||||
}
|
||||
LoopEvent::ToolStart { name, input } => {
|
||||
tracing::debug!("[agent_chat_stream] ToolStart: {}", name);
|
||||
if name.starts_with("hand_") {
|
||||
StreamChatEvent::HandStart { name: name.clone(), params: input.clone() }
|
||||
} else {
|
||||
StreamChatEvent::ToolStart { name: name.clone(), input: input.clone() }
|
||||
}
|
||||
}
|
||||
LoopEvent::ToolEnd { name, output } => {
|
||||
tracing::debug!("[agent_chat_stream] ToolEnd: {}", name);
|
||||
if name.starts_with("hand_") {
|
||||
StreamChatEvent::HandEnd { name: name.clone(), result: output.clone() }
|
||||
} else {
|
||||
StreamChatEvent::ToolEnd { name: name.clone(), output: output.clone() }
|
||||
}
|
||||
}
|
||||
LoopEvent::IterationStart { iteration, max_iterations } => {
|
||||
tracing::debug!("[agent_chat_stream] IterationStart: {}/{}", iteration, max_iterations);
|
||||
StreamChatEvent::IterationStart { iteration: *iteration, max_iterations: *max_iterations }
|
||||
}
|
||||
LoopEvent::Complete(result) => {
|
||||
tracing::info!("[agent_chat_stream] Complete: input_tokens={}, output_tokens={}",
|
||||
result.input_tokens, result.output_tokens);
|
||||
|
||||
let agent_id_hook = agent_id_str.clone();
|
||||
let message_hook = message.clone();
|
||||
let hb = hb_state.clone();
|
||||
let rf = rf_state.clone();
|
||||
let driver = llm_driver.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::intelligence_hooks::post_conversation_hook(
|
||||
&agent_id_hook, &message_hook, &hb, &rf, driver,
|
||||
).await;
|
||||
});
|
||||
|
||||
StreamChatEvent::Complete {
|
||||
input_tokens: result.input_tokens,
|
||||
output_tokens: result.output_tokens,
|
||||
}
|
||||
}
|
||||
LoopEvent::Error(message) => {
|
||||
tracing::warn!("[agent_chat_stream] Error: {}", message);
|
||||
StreamChatEvent::Error { message: message.clone() }
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = app.emit("stream:chunk", serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"event": stream_event
|
||||
})) {
|
||||
tracing::warn!("[agent_chat_stream] Failed to emit event: {}", e);
|
||||
break;
|
||||
}
|
||||
|
||||
if matches!(event, LoopEvent::Complete(_) | LoopEvent::Error(_)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::info!("[agent_chat_stream] Stream channel closed for session: {}", session_id);
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("[agent_chat_stream] Stream idle timeout for session: {}", session_id);
|
||||
let _ = app.emit("stream:chunk", serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"event": StreamChatEvent::Error {
|
||||
message: "流式响应超时,请重试".to_string()
|
||||
}
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("[agent_chat_stream] Stream processing ended for session: {}", session_id);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
431
desktop/src-tauri/src/kernel_commands/hand.rs
Normal file
431
desktop/src-tauri/src/kernel_commands/hand.rs
Normal file
@@ -0,0 +1,431 @@
|
||||
//! Hand commands: list, execute, approve, cancel, get, run_status, run_list, run_cancel
|
||||
//!
|
||||
//! Hands are autonomous capabilities registered in the Kernel's HandRegistry.
|
||||
//! Hand execution can require approval depending on autonomy level and config.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use super::KernelState;
|
||||
|
||||
// ============================================================================
|
||||
// Hands Commands - Autonomous Capabilities
|
||||
// ============================================================================
|
||||
|
||||
/// Hand information response for frontend
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HandInfoResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub requirements_met: bool,
|
||||
pub needs_approval: bool,
|
||||
pub dependencies: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tool_count: u32,
|
||||
#[serde(default)]
|
||||
pub metric_count: u32,
|
||||
}
|
||||
|
||||
impl From<zclaw_hands::HandConfig> for HandInfoResponse {
|
||||
fn from(config: zclaw_hands::HandConfig) -> Self {
|
||||
// Determine status based on enabled and dependencies
|
||||
let status = if !config.enabled {
|
||||
"unavailable".to_string()
|
||||
} else if config.needs_approval {
|
||||
"needs_approval".to_string()
|
||||
} else {
|
||||
"idle".to_string()
|
||||
};
|
||||
|
||||
// Extract category from tags if present
|
||||
let category = config.tags.iter().find(|t| {
|
||||
["research", "automation", "browser", "data", "media", "communication"].contains(&t.as_str())
|
||||
}).cloned();
|
||||
|
||||
// Map tags to icon
|
||||
let icon = if config.tags.contains(&"browser".to_string()) {
|
||||
Some("globe".to_string())
|
||||
} else if config.tags.contains(&"research".to_string()) {
|
||||
Some("search".to_string())
|
||||
} else if config.tags.contains(&"media".to_string()) {
|
||||
Some("video".to_string())
|
||||
} else if config.tags.contains(&"data".to_string()) {
|
||||
Some("database".to_string())
|
||||
} else if config.tags.contains(&"communication".to_string()) {
|
||||
Some("message-circle".to_string())
|
||||
} else {
|
||||
Some("zap".to_string())
|
||||
};
|
||||
|
||||
Self {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
status,
|
||||
requirements_met: config.enabled && config.dependencies.is_empty(),
|
||||
needs_approval: config.needs_approval,
|
||||
dependencies: config.dependencies,
|
||||
tags: config.tags,
|
||||
enabled: config.enabled,
|
||||
category,
|
||||
icon,
|
||||
tool_count: 0,
|
||||
metric_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand execution result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HandResult {
|
||||
pub success: bool,
|
||||
pub output: serde_json::Value,
|
||||
pub error: Option<String>,
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<zclaw_hands::HandResult> for HandResult {
|
||||
fn from(result: zclaw_hands::HandResult) -> Self {
|
||||
Self {
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
duration_ms: result.duration_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all registered hands
|
||||
///
|
||||
/// Returns hands from the Kernel's HandRegistry.
|
||||
/// Hands are registered during kernel initialization.
|
||||
#[tauri::command]
|
||||
pub async fn hand_list(
|
||||
state: State<'_, KernelState>,
|
||||
) -> Result<Vec<HandInfoResponse>, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let hands = kernel.list_hands().await;
|
||||
Ok(hands.into_iter().map(HandInfoResponse::from).collect())
|
||||
}
|
||||
|
||||
/// Execute a hand
|
||||
///
|
||||
/// Executes a hand with the given ID and input.
|
||||
/// If the hand has `needs_approval = true`, creates a pending approval instead.
|
||||
/// Returns the hand result as JSON, or a pending status with approval ID.
|
||||
#[tauri::command]
|
||||
pub async fn hand_execute(
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
input: serde_json::Value,
|
||||
autonomy_level: Option<String>,
|
||||
) -> Result<HandResult, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
// Autonomy guard: supervised mode requires approval for ALL hands
|
||||
if autonomy_level.as_deref() == Some("supervised") {
|
||||
let approval = kernel.create_approval(id.clone(), input).await;
|
||||
return Ok(HandResult {
|
||||
success: false,
|
||||
output: serde_json::json!({
|
||||
"status": "pending_approval",
|
||||
"approval_id": approval.id,
|
||||
"hand_id": approval.hand_id,
|
||||
"message": "监督模式下所有 Hand 执行需要用户审批"
|
||||
}),
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if hand requires approval (assisted mode or no autonomy level specified).
|
||||
// In autonomous mode, the user has opted in to bypass per-hand approval gates.
|
||||
if autonomy_level.as_deref() != Some("autonomous") {
|
||||
let hands = kernel.list_hands().await;
|
||||
if let Some(hand_config) = hands.iter().find(|h| h.id == id) {
|
||||
if hand_config.needs_approval {
|
||||
let approval = kernel.create_approval(id.clone(), input).await;
|
||||
return Ok(HandResult {
|
||||
success: false,
|
||||
output: serde_json::json!({
|
||||
"status": "pending_approval",
|
||||
"approval_id": approval.id,
|
||||
"hand_id": approval.hand_id,
|
||||
"message": "This hand requires approval before execution"
|
||||
}),
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute hand directly (returns result + run_id for tracking)
|
||||
let (result, _run_id) = kernel.execute_hand(&id, input).await
|
||||
.map_err(|e| format!("Failed to execute hand: {}", e))?;
|
||||
|
||||
Ok(HandResult::from(result))
|
||||
}
|
||||
|
||||
/// Approve a hand execution
|
||||
///
|
||||
/// When approved, the kernel's `respond_to_approval` internally spawns the Hand
|
||||
/// execution. We additionally emit Tauri events so the frontend can track when
|
||||
/// the execution finishes.
|
||||
#[tauri::command]
|
||||
pub async fn hand_approve(
|
||||
app: AppHandle,
|
||||
state: State<'_, KernelState>,
|
||||
hand_name: String,
|
||||
run_id: String,
|
||||
approved: bool,
|
||||
reason: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
tracing::info!(
|
||||
"[hand_approve] hand={}, run_id={}, approved={}, reason={:?}",
|
||||
hand_name, run_id, approved, reason
|
||||
);
|
||||
|
||||
// Verify the approval belongs to the specified hand before responding.
|
||||
// This prevents cross-hand approval attacks where a run_id from one hand
|
||||
// is used to approve a different hand's pending execution.
|
||||
let approvals = kernel.list_approvals().await;
|
||||
let entry = approvals.iter().find(|a| a.id == run_id && a.status == "pending")
|
||||
.ok_or_else(|| format!("Approval not found or already resolved: {}", run_id))?;
|
||||
|
||||
if entry.hand_id != hand_name {
|
||||
return Err(format!(
|
||||
"Approval run_id {} belongs to hand '{}', not '{}' as requested",
|
||||
run_id, entry.hand_id, hand_name
|
||||
));
|
||||
}
|
||||
|
||||
kernel.respond_to_approval(&run_id, approved, reason).await
|
||||
.map_err(|e| format!("Failed to approve hand: {}", e))?;
|
||||
|
||||
// When approved, monitor the Hand execution and emit events to the frontend
|
||||
if approved {
|
||||
let approval_id = run_id.clone();
|
||||
let hand_id = hand_name.clone();
|
||||
let kernel_state: KernelState = (*state).clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Poll the approval status until it transitions from "approved" to
|
||||
// "completed" or "failed" (set by the kernel's spawned task).
|
||||
// Timeout after 5 minutes to avoid hanging forever.
|
||||
let timeout = tokio::time::Duration::from_secs(300);
|
||||
let poll_interval = tokio::time::Duration::from_millis(500);
|
||||
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
let kernel_lock = kernel_state.lock().await;
|
||||
if let Some(kernel) = kernel_lock.as_ref() {
|
||||
// Use get_approval to check any status (not just "pending")
|
||||
if let Some(entry) = kernel.get_approval(&approval_id).await {
|
||||
match entry.status.as_str() {
|
||||
"completed" => {
|
||||
tracing::info!("[hand_approve] Hand '{}' execution completed for approval {}", hand_id, approval_id);
|
||||
return (true, None::<String>);
|
||||
}
|
||||
"failed" => {
|
||||
let error_msg = entry.input.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string();
|
||||
tracing::warn!("[hand_approve] Hand '{}' execution failed for approval {}: {}", hand_id, approval_id, error_msg);
|
||||
return (false, Some(error_msg));
|
||||
}
|
||||
_ => {} // still running (status is "approved")
|
||||
}
|
||||
} else {
|
||||
// Entry disappeared entirely — kernel was likely restarted
|
||||
return (false, Some("Approval entry disappeared".to_string()));
|
||||
}
|
||||
} else {
|
||||
return (false, Some("Kernel not available".to_string()));
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
|
||||
let (success, error) = match result {
|
||||
Ok((s, e)) => (s, e),
|
||||
Err(_) => (false, Some("Hand execution timed out (5 minutes)".to_string())),
|
||||
};
|
||||
|
||||
let _ = app.emit("hand-execution-complete", serde_json::json!({
|
||||
"approvalId": approval_id,
|
||||
"handId": hand_id,
|
||||
"success": success,
|
||||
"error": error,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": if approved { "approved" } else { "rejected" },
|
||||
"hand_name": hand_name,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel a hand execution
|
||||
#[tauri::command]
|
||||
pub async fn hand_cancel(
|
||||
state: State<'_, KernelState>,
|
||||
hand_name: String,
|
||||
run_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
tracing::info!(
|
||||
"[hand_cancel] hand={}, run_id={}",
|
||||
hand_name, run_id
|
||||
);
|
||||
|
||||
// Verify the approval belongs to the specified hand before cancelling
|
||||
let approvals = kernel.list_approvals().await;
|
||||
let entry = approvals.iter().find(|a| a.id == run_id && a.status == "pending")
|
||||
.ok_or_else(|| format!("Approval not found or already resolved: {}", run_id))?;
|
||||
|
||||
if entry.hand_id != hand_name {
|
||||
return Err(format!(
|
||||
"Approval run_id {} belongs to hand '{}', not '{}' as requested",
|
||||
run_id, entry.hand_id, hand_name
|
||||
));
|
||||
}
|
||||
|
||||
kernel.cancel_approval(&run_id).await
|
||||
.map_err(|e| format!("Failed to cancel hand: {}", e))?;
|
||||
|
||||
Ok(serde_json::json!({ "status": "cancelled", "hand_name": hand_name }))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Hand Stub Commands (not yet fully implemented)
|
||||
// ============================================================
|
||||
|
||||
/// Get detailed info for a single hand
|
||||
#[tauri::command]
|
||||
pub async fn hand_get(
|
||||
state: State<'_, KernelState>,
|
||||
name: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let hands = kernel.list_hands().await;
|
||||
let found = hands.iter().find(|h| h.id == name)
|
||||
.ok_or_else(|| format!("Hand '{}' not found", name))?;
|
||||
|
||||
Ok(serde_json::to_value(found)
|
||||
.map_err(|e| format!("Serialization error: {}", e))?)
|
||||
}
|
||||
|
||||
/// Get status of a specific hand run
|
||||
#[tauri::command]
|
||||
pub async fn hand_run_status(
|
||||
state: State<'_, KernelState>,
|
||||
run_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let parsed_id: zclaw_types::HandRunId = run_id.parse()
|
||||
.map_err(|e| format!("Invalid run ID: {}", e))?;
|
||||
|
||||
let run = kernel.get_hand_run(&parsed_id).await
|
||||
.map_err(|e| format!("Failed to get hand run: {}", e))?;
|
||||
|
||||
match run {
|
||||
Some(r) => Ok(serde_json::to_value(r)
|
||||
.map_err(|e| format!("Serialization error: {}", e))?),
|
||||
None => Ok(serde_json::json!({
|
||||
"status": "not_found",
|
||||
"run_id": run_id,
|
||||
"message": "Hand run not found"
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// List run history for a hand (or all hands)
|
||||
#[tauri::command]
|
||||
pub async fn hand_run_list(
|
||||
state: State<'_, KernelState>,
|
||||
hand_name: Option<String>,
|
||||
status: Option<String>,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let filter = zclaw_types::HandRunFilter {
|
||||
hand_name,
|
||||
status: status.map(|s| s.parse()).transpose()
|
||||
.map_err(|e| format!("Invalid status filter: {}", e))?,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
|
||||
let runs = kernel.list_hand_runs(&filter).await
|
||||
.map_err(|e| format!("Failed to list hand runs: {}", e))?;
|
||||
let total = kernel.count_hand_runs(&filter).await
|
||||
.map_err(|e| format!("Failed to count hand runs: {}", e))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"runs": runs,
|
||||
"total": total,
|
||||
"limit": filter.limit.unwrap_or(20),
|
||||
"offset": filter.offset.unwrap_or(0),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cancel a running hand execution
|
||||
#[tauri::command]
|
||||
pub async fn hand_run_cancel(
|
||||
state: State<'_, KernelState>,
|
||||
run_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let parsed_id: zclaw_types::HandRunId = run_id.parse()
|
||||
.map_err(|e| format!("Invalid run ID: {}", e))?;
|
||||
|
||||
kernel.cancel_hand_run(&parsed_id).await
|
||||
.map_err(|e| format!("Failed to cancel hand run: {}", e))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "cancelled",
|
||||
"run_id": run_id
|
||||
}))
|
||||
}
|
||||
251
desktop/src-tauri/src/kernel_commands/lifecycle.rs
Normal file
251
desktop/src-tauri/src/kernel_commands/lifecycle.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
//! Kernel lifecycle commands: init, status, shutdown
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use super::{KernelState, SchedulerState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn default_api_protocol() -> String { "openai".to_string() }
|
||||
fn default_kernel_provider() -> String { "openai".to_string() }
|
||||
fn default_kernel_model() -> String { "gpt-4o-mini".to_string() }
|
||||
|
||||
/// Kernel configuration request
|
||||
///
|
||||
/// Simple configuration: base_url + api_key + model
|
||||
/// Model ID is passed directly to the API without any transformation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelConfigRequest {
|
||||
/// LLM provider (for preset URLs): anthropic, openai, zhipu, kimi, qwen, deepseek, local, custom
|
||||
#[serde(default = "default_kernel_provider")]
|
||||
pub provider: String,
|
||||
/// Model identifier - passed directly to the API
|
||||
#[serde(default = "default_kernel_model")]
|
||||
pub model: String,
|
||||
/// API key
|
||||
pub api_key: Option<String>,
|
||||
/// Base URL (optional, uses provider default if not specified)
|
||||
pub base_url: Option<String>,
|
||||
/// API protocol: openai or anthropic
|
||||
#[serde(default = "default_api_protocol")]
|
||||
pub api_protocol: String,
|
||||
}
|
||||
|
||||
/// Kernel status response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelStatusResponse {
|
||||
pub initialized: bool,
|
||||
pub agent_count: usize,
|
||||
pub database_url: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Initialize the internal ZCLAW Kernel
|
||||
///
|
||||
/// If kernel already exists with the same config, returns existing status.
|
||||
/// If config changed, reboots kernel with new config.
|
||||
#[tauri::command]
|
||||
pub async fn kernel_init(
|
||||
state: State<'_, KernelState>,
|
||||
scheduler_state: State<'_, SchedulerState>,
|
||||
config_request: Option<KernelConfigRequest>,
|
||||
) -> Result<KernelStatusResponse, String> {
|
||||
let mut kernel_lock = state.lock().await;
|
||||
|
||||
// Check if we need to reboot kernel with new config
|
||||
if let Some(kernel) = kernel_lock.as_ref() {
|
||||
// Get current config from kernel
|
||||
let current_config = kernel.config();
|
||||
|
||||
// Check if config changed
|
||||
let config_changed = if let Some(ref req) = config_request {
|
||||
let default_base_url = zclaw_kernel::config::KernelConfig::from_provider(
|
||||
&req.provider, "", &req.model, None, &req.api_protocol
|
||||
).llm.base_url;
|
||||
let request_base_url = req.base_url.clone().unwrap_or(default_base_url.clone());
|
||||
|
||||
current_config.llm.model != req.model ||
|
||||
current_config.llm.base_url != request_base_url
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !config_changed {
|
||||
// Same config, return existing status
|
||||
return Ok(KernelStatusResponse {
|
||||
initialized: true,
|
||||
agent_count: kernel.list_agents().len(),
|
||||
database_url: None,
|
||||
base_url: Some(current_config.llm.base_url.clone()),
|
||||
model: Some(current_config.llm.model.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// Config changed, need to reboot kernel
|
||||
// Shutdown old kernel
|
||||
if let Err(e) = kernel.shutdown().await {
|
||||
eprintln!("[kernel_init] Warning: Failed to shutdown old kernel: {}", e);
|
||||
}
|
||||
*kernel_lock = None;
|
||||
}
|
||||
|
||||
// Build configuration from request
|
||||
let config = if let Some(req) = &config_request {
|
||||
let api_key = req.api_key.as_deref().unwrap_or("");
|
||||
let base_url = req.base_url.as_deref();
|
||||
|
||||
zclaw_kernel::config::KernelConfig::from_provider(
|
||||
&req.provider,
|
||||
api_key,
|
||||
&req.model,
|
||||
base_url,
|
||||
&req.api_protocol,
|
||||
)
|
||||
} else {
|
||||
zclaw_kernel::config::KernelConfig::default()
|
||||
};
|
||||
|
||||
// Debug: print skills directory
|
||||
if let Some(ref skills_dir) = config.skills_dir {
|
||||
println!("[kernel_init] Skills directory: {} (exists: {})", skills_dir.display(), skills_dir.exists());
|
||||
} else {
|
||||
println!("[kernel_init] No skills directory configured");
|
||||
}
|
||||
|
||||
let base_url = config.llm.base_url.clone();
|
||||
let model = config.llm.model.clone();
|
||||
|
||||
// Boot kernel
|
||||
let mut kernel = zclaw_kernel::Kernel::boot(config.clone())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to initialize kernel: {}", e))?;
|
||||
|
||||
let agent_count = kernel.list_agents().len();
|
||||
|
||||
// Configure extraction driver so the Growth system can call LLM for memory extraction
|
||||
let driver = kernel.driver();
|
||||
crate::intelligence::extraction_adapter::configure_extraction_driver(
|
||||
driver.clone(),
|
||||
model.clone(),
|
||||
);
|
||||
|
||||
// Bridge SqliteStorage to Kernel's GrowthIntegration
|
||||
{
|
||||
match crate::viking_commands::get_storage().await {
|
||||
Ok(sqlite_storage) => {
|
||||
let viking = std::sync::Arc::new(zclaw_runtime::VikingAdapter::new(sqlite_storage));
|
||||
kernel.set_viking(viking);
|
||||
tracing::info!("[kernel_init] Bridged persistent SqliteStorage to Kernel GrowthIntegration");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[kernel_init] Failed to get SqliteStorage, GrowthIntegration will use in-memory storage: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the LLM extraction driver on the kernel for memory extraction via middleware
|
||||
let extraction_driver = crate::intelligence::extraction_adapter::TauriExtractionDriver::new(
|
||||
driver.clone(),
|
||||
model.clone(),
|
||||
);
|
||||
kernel.set_extraction_driver(std::sync::Arc::new(extraction_driver));
|
||||
}
|
||||
|
||||
// Configure summary driver so the Growth system can generate L0/L1 summaries
|
||||
if let Some(api_key) = config_request.as_ref().and_then(|r| r.api_key.clone()) {
|
||||
crate::summarizer_adapter::configure_summary_driver(
|
||||
crate::summarizer_adapter::TauriSummaryDriver::new(
|
||||
format!("{}/chat/completions", base_url),
|
||||
api_key,
|
||||
Some(model.clone()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
*kernel_lock = Some(kernel);
|
||||
|
||||
// Start SchedulerService — periodically checks and fires scheduled triggers
|
||||
{
|
||||
let mut sched_lock = scheduler_state.lock().await;
|
||||
// Stop old scheduler if any
|
||||
if let Some(ref old) = *sched_lock {
|
||||
old.stop();
|
||||
}
|
||||
let scheduler = zclaw_kernel::scheduler::SchedulerService::new(
|
||||
state.inner().clone(),
|
||||
60, // check every 60 seconds
|
||||
);
|
||||
scheduler.start();
|
||||
tracing::info!("[kernel_init] SchedulerService started (60s interval)");
|
||||
*sched_lock = Some(scheduler);
|
||||
}
|
||||
|
||||
Ok(KernelStatusResponse {
|
||||
initialized: true,
|
||||
agent_count,
|
||||
database_url: Some(config.database_url),
|
||||
base_url: Some(base_url),
|
||||
model: Some(model),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get kernel status
|
||||
#[tauri::command]
|
||||
pub async fn kernel_status(
|
||||
state: State<'_, KernelState>,
|
||||
) -> Result<KernelStatusResponse, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
match kernel_lock.as_ref() {
|
||||
Some(kernel) => Ok(KernelStatusResponse {
|
||||
initialized: true,
|
||||
agent_count: kernel.list_agents().len(),
|
||||
database_url: Some(kernel.config().database_url.clone()),
|
||||
base_url: Some(kernel.config().llm.base_url.clone()),
|
||||
model: Some(kernel.config().llm.model.clone()),
|
||||
}),
|
||||
None => Ok(KernelStatusResponse {
|
||||
initialized: false,
|
||||
agent_count: 0,
|
||||
database_url: None,
|
||||
base_url: None,
|
||||
model: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shutdown the kernel
|
||||
#[tauri::command]
|
||||
pub async fn kernel_shutdown(
|
||||
state: State<'_, KernelState>,
|
||||
scheduler_state: State<'_, SchedulerState>,
|
||||
) -> Result<(), String> {
|
||||
// Stop scheduler first
|
||||
{
|
||||
let mut sched_lock = scheduler_state.lock().await;
|
||||
if let Some(scheduler) = sched_lock.take() {
|
||||
scheduler.stop();
|
||||
tracing::info!("[kernel_shutdown] SchedulerService stopped");
|
||||
}
|
||||
}
|
||||
|
||||
let mut kernel_lock = state.lock().await;
|
||||
|
||||
if let Some(kernel) = kernel_lock.take() {
|
||||
kernel.shutdown().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
72
desktop/src-tauri/src/kernel_commands/mod.rs
Normal file
72
desktop/src-tauri/src/kernel_commands/mod.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! ZCLAW Kernel commands for Tauri
|
||||
//!
|
||||
//! These commands provide direct access to the internal ZCLAW Kernel,
|
||||
//! eliminating the need for external ZCLAW process.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use zclaw_kernel::Kernel;
|
||||
|
||||
pub mod agent;
|
||||
pub mod approval;
|
||||
pub mod chat;
|
||||
pub mod hand;
|
||||
pub mod lifecycle;
|
||||
pub mod scheduled_task;
|
||||
pub mod skill;
|
||||
pub mod trigger;
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub mod a2a;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Kernel state wrapper for Tauri
|
||||
pub type KernelState = Arc<Mutex<Option<Kernel>>>;
|
||||
|
||||
/// Scheduler state — holds a reference to the SchedulerService so it can be stopped on shutdown
|
||||
pub type SchedulerState = Arc<Mutex<Option<zclaw_kernel::scheduler::SchedulerService>>>;
|
||||
|
||||
/// Session-level stream concurrency guard.
|
||||
/// Prevents two concurrent `agent_chat_stream` calls from interleaving events
|
||||
/// for the same session_id.
|
||||
pub type SessionStreamGuard = Arc<dashmap::DashMap<String, Arc<Mutex<()>>>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared validation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validate an agent ID string with clear error messages
|
||||
pub(crate) fn validate_agent_id(agent_id: &str) -> Result<String, String> {
|
||||
crate::intelligence::validation::validate_identifier(agent_id, "agent_id")
|
||||
.map_err(|e| format!("Invalid agent_id: {}", e))?;
|
||||
// AgentId is a UUID wrapper — validate UUID format for better error messages
|
||||
if agent_id.contains('-') {
|
||||
crate::intelligence::validation::validate_uuid(agent_id, "agent_id")
|
||||
.map_err(|e| format!("Invalid agent_id: {}", e))?;
|
||||
}
|
||||
Ok(agent_id.to_string())
|
||||
}
|
||||
|
||||
/// Validate a generic ID string (for skills, hands, triggers, etc.)
|
||||
pub(crate) fn validate_id(id: &str, field_name: &str) -> Result<String, String> {
|
||||
crate::intelligence::validation::validate_identifier(id, field_name)
|
||||
.map_err(|e| format!("Invalid {}: {}", field_name, e))?;
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create the kernel state for Tauri
|
||||
pub fn create_kernel_state() -> KernelState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
/// Create the scheduler state for Tauri
|
||||
pub fn create_scheduler_state() -> SchedulerState {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
124
desktop/src-tauri/src/kernel_commands/scheduled_task.rs
Normal file
124
desktop/src-tauri/src/kernel_commands/scheduled_task.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
//! Scheduled task commands
|
||||
//!
|
||||
//! Tasks are backed by kernel triggers (Schedule type).
|
||||
//! The SchedulerService checks every 60 seconds for due triggers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use super::KernelState;
|
||||
|
||||
// ============================================================
|
||||
// Scheduled Task Commands
|
||||
// ============================================================
|
||||
|
||||
/// Request to create a scheduled task (maps to kernel trigger)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateScheduledTaskRequest {
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
pub schedule_type: String,
|
||||
pub target: Option<ScheduledTaskTarget>,
|
||||
pub description: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Target for a scheduled task
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledTaskTarget {
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Response for scheduled task creation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledTaskResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Create a scheduled task (backed by kernel TriggerManager)
|
||||
///
|
||||
/// Tasks are automatically executed by the SchedulerService which checks
|
||||
/// every 60 seconds for due triggers.
|
||||
#[tauri::command]
|
||||
pub async fn scheduled_task_create(
|
||||
state: State<'_, KernelState>,
|
||||
request: CreateScheduledTaskRequest,
|
||||
) -> Result<ScheduledTaskResponse, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
// Build TriggerConfig from request
|
||||
let trigger_type = match request.schedule_type.as_str() {
|
||||
"cron" | "schedule" => zclaw_hands::TriggerType::Schedule {
|
||||
cron: request.schedule.clone(),
|
||||
},
|
||||
"interval" => zclaw_hands::TriggerType::Schedule {
|
||||
cron: request.schedule.clone(), // interval as simplified cron
|
||||
},
|
||||
"once" => zclaw_hands::TriggerType::Schedule {
|
||||
cron: request.schedule.clone(),
|
||||
},
|
||||
_ => return Err(format!("Unsupported schedule type: {}", request.schedule_type)),
|
||||
};
|
||||
|
||||
let target_id = request.target.as_ref().map(|t| t.id.clone()).unwrap_or_default();
|
||||
let task_id = format!("sched_{}", chrono::Utc::now().timestamp_millis());
|
||||
|
||||
let config = zclaw_hands::TriggerConfig {
|
||||
id: task_id.clone(),
|
||||
name: request.name.clone(),
|
||||
hand_id: target_id,
|
||||
trigger_type,
|
||||
enabled: request.enabled.unwrap_or(true),
|
||||
max_executions_per_hour: 60,
|
||||
};
|
||||
|
||||
let entry = kernel.create_trigger(config).await
|
||||
.map_err(|e| format!("Failed to create scheduled task: {}", e))?;
|
||||
|
||||
Ok(ScheduledTaskResponse {
|
||||
id: entry.config.id,
|
||||
name: entry.config.name,
|
||||
schedule: request.schedule,
|
||||
status: "active".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// List all scheduled tasks (kernel triggers of Schedule type)
|
||||
#[tauri::command]
|
||||
pub async fn scheduled_task_list(
|
||||
state: State<'_, KernelState>,
|
||||
) -> Result<Vec<ScheduledTaskResponse>, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let triggers = kernel.list_triggers().await;
|
||||
let tasks: Vec<ScheduledTaskResponse> = triggers
|
||||
.into_iter()
|
||||
.filter(|t| matches!(t.config.trigger_type, zclaw_hands::TriggerType::Schedule { .. }))
|
||||
.map(|t| {
|
||||
let schedule = match t.config.trigger_type {
|
||||
zclaw_hands::TriggerType::Schedule { cron } => cron,
|
||||
_ => String::new(),
|
||||
};
|
||||
ScheduledTaskResponse {
|
||||
id: t.config.id,
|
||||
name: t.config.name,
|
||||
schedule,
|
||||
status: if t.config.enabled { "active".to_string() } else { "paused".to_string() },
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(tasks)
|
||||
}
|
||||
350
desktop/src-tauri/src/kernel_commands/skill.rs
Normal file
350
desktop/src-tauri/src/kernel_commands/skill.rs
Normal file
@@ -0,0 +1,350 @@
|
||||
//! Skill CRUD + execute commands
|
||||
//!
|
||||
//! Skills are loaded from the Kernel's SkillRegistry.
|
||||
//! Skills are registered during kernel initialization.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tauri::State;
|
||||
use zclaw_types::SkillId;
|
||||
|
||||
use super::{validate_id, KernelState};
|
||||
use crate::intelligence::validation::validate_identifier;
|
||||
|
||||
// ============================================================================
|
||||
// Skills Commands - Dynamic Discovery
|
||||
// ============================================================================
|
||||
|
||||
/// Skill information response for frontend
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillInfoResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub version: String,
|
||||
pub capabilities: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub mode: String,
|
||||
pub enabled: bool,
|
||||
pub triggers: Vec<String>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
impl From<zclaw_skills::SkillManifest> for SkillInfoResponse {
|
||||
fn from(manifest: zclaw_skills::SkillManifest) -> Self {
|
||||
Self {
|
||||
id: manifest.id.to_string(),
|
||||
name: manifest.name,
|
||||
description: manifest.description,
|
||||
version: manifest.version,
|
||||
capabilities: manifest.capabilities,
|
||||
tags: manifest.tags,
|
||||
mode: format!("{:?}", manifest.mode),
|
||||
enabled: manifest.enabled,
|
||||
triggers: manifest.triggers,
|
||||
category: manifest.category,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all discovered skills
|
||||
///
|
||||
/// Returns skills from the Kernel's SkillRegistry.
|
||||
/// Skills are loaded from the skills/ directory during kernel initialization.
|
||||
#[tauri::command]
|
||||
pub async fn skill_list(
|
||||
state: State<'_, KernelState>,
|
||||
) -> Result<Vec<SkillInfoResponse>, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let skills = kernel.list_skills().await;
|
||||
println!("[skill_list] Found {} skills", skills.len());
|
||||
for skill in &skills {
|
||||
println!("[skill_list] - {} ({})", skill.name, skill.id);
|
||||
}
|
||||
Ok(skills.into_iter().map(SkillInfoResponse::from).collect())
|
||||
}
|
||||
|
||||
/// Refresh skills from a directory
|
||||
///
|
||||
/// Re-scans the skills directory for new or updated skills.
|
||||
/// Optionally accepts a custom directory path to scan.
|
||||
#[tauri::command]
|
||||
pub async fn skill_refresh(
|
||||
state: State<'_, KernelState>,
|
||||
skill_dir: Option<String>,
|
||||
) -> Result<Vec<SkillInfoResponse>, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
// Convert optional string to PathBuf
|
||||
let dir_path = skill_dir.map(PathBuf::from);
|
||||
|
||||
// Refresh skills
|
||||
kernel.refresh_skills(dir_path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh skills: {}", e))?;
|
||||
|
||||
// Return updated list
|
||||
let skills = kernel.list_skills().await;
|
||||
Ok(skills.into_iter().map(SkillInfoResponse::from).collect())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill CRUD Commands
|
||||
// ============================================================================
|
||||
|
||||
/// Request body for creating a new skill
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSkillRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub triggers: Vec<String>,
|
||||
pub actions: Vec<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Request body for updating a skill
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateSkillRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub triggers: Option<Vec<String>>,
|
||||
pub actions: Option<Vec<String>>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Create a new skill in the skills directory
|
||||
#[tauri::command]
|
||||
pub async fn skill_create(
|
||||
state: State<'_, KernelState>,
|
||||
request: CreateSkillRequest,
|
||||
) -> Result<SkillInfoResponse, String> {
|
||||
let name = request.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("Skill name cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Generate skill ID from name
|
||||
let id = name.to_lowercase()
|
||||
.replace(' ', "-")
|
||||
.replace(|c: char| !c.is_alphanumeric() && c != '-', "");
|
||||
|
||||
validate_identifier(&id, "skill_id")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
let manifest = zclaw_skills::SkillManifest {
|
||||
id: SkillId::new(&id),
|
||||
name: name.clone(),
|
||||
description: request.description.unwrap_or_default(),
|
||||
version: "1.0.0".to_string(),
|
||||
author: None,
|
||||
mode: zclaw_skills::SkillMode::PromptOnly,
|
||||
capabilities: request.actions,
|
||||
input_schema: None,
|
||||
output_schema: None,
|
||||
tags: vec![],
|
||||
category: None,
|
||||
triggers: request.triggers,
|
||||
enabled: request.enabled.unwrap_or(true),
|
||||
};
|
||||
|
||||
kernel.create_skill(manifest.clone())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create skill: {}", e))?;
|
||||
|
||||
Ok(SkillInfoResponse::from(manifest))
|
||||
}
|
||||
|
||||
/// Update an existing skill
|
||||
#[tauri::command]
|
||||
pub async fn skill_update(
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
request: UpdateSkillRequest,
|
||||
) -> Result<SkillInfoResponse, String> {
|
||||
validate_identifier(&id, "skill_id")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
// Get existing manifest
|
||||
let existing = kernel.skills()
|
||||
.get_manifest(&SkillId::new(&id))
|
||||
.await
|
||||
.ok_or_else(|| format!("Skill not found: {}", id))?;
|
||||
|
||||
// Build updated manifest from existing + request fields
|
||||
let updated = zclaw_skills::SkillManifest {
|
||||
id: existing.id.clone(),
|
||||
name: request.name.unwrap_or(existing.name),
|
||||
description: request.description.unwrap_or(existing.description),
|
||||
version: existing.version.clone(),
|
||||
author: existing.author.clone(),
|
||||
mode: existing.mode.clone(),
|
||||
capabilities: request.actions.unwrap_or(existing.capabilities),
|
||||
input_schema: existing.input_schema.clone(),
|
||||
output_schema: existing.output_schema.clone(),
|
||||
tags: existing.tags.clone(),
|
||||
category: existing.category.clone(),
|
||||
triggers: request.triggers.unwrap_or(existing.triggers),
|
||||
enabled: request.enabled.unwrap_or(existing.enabled),
|
||||
};
|
||||
|
||||
let result = kernel.update_skill(&SkillId::new(&id), updated)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update skill: {}", e))?;
|
||||
|
||||
Ok(SkillInfoResponse::from(result))
|
||||
}
|
||||
|
||||
/// Delete a skill
|
||||
#[tauri::command]
|
||||
pub async fn skill_delete(
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
validate_identifier(&id, "skill_id")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
kernel.delete_skill(&SkillId::new(&id))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete skill: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Skill Execution Command
|
||||
// ============================================================================
|
||||
|
||||
/// Skill execution context
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillContext {
|
||||
pub agent_id: String,
|
||||
pub session_id: String,
|
||||
pub working_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SkillContext> for zclaw_skills::SkillContext {
|
||||
fn from(ctx: SkillContext) -> Self {
|
||||
Self {
|
||||
agent_id: ctx.agent_id,
|
||||
session_id: ctx.session_id,
|
||||
working_dir: ctx.working_dir.map(std::path::PathBuf::from),
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: 300,
|
||||
network_allowed: true,
|
||||
file_access_allowed: true,
|
||||
llm: None, // Injected by Kernel.execute_skill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skill execution result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillResult {
|
||||
pub success: bool,
|
||||
pub output: serde_json::Value,
|
||||
pub error: Option<String>,
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<zclaw_skills::SkillResult> for SkillResult {
|
||||
fn from(result: zclaw_skills::SkillResult) -> Self {
|
||||
Self {
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
duration_ms: result.duration_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a skill
|
||||
///
|
||||
/// Executes a skill with the given ID and input.
|
||||
/// Returns the skill result as JSON.
|
||||
#[tauri::command]
|
||||
pub async fn skill_execute(
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
context: SkillContext,
|
||||
input: serde_json::Value,
|
||||
autonomy_level: Option<String>,
|
||||
) -> Result<SkillResult, String> {
|
||||
// Validate skill ID
|
||||
let id = validate_id(&id, "skill_id")?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
|
||||
|
||||
// Autonomy guard: supervised mode creates an approval request for ALL skills
|
||||
if autonomy_level.as_deref() == Some("supervised") {
|
||||
let approval = kernel.create_approval(id.clone(), input).await;
|
||||
return Ok(SkillResult {
|
||||
success: false,
|
||||
output: serde_json::json!({
|
||||
"status": "pending_approval",
|
||||
"approval_id": approval.id,
|
||||
"skill_id": approval.hand_id,
|
||||
"message": "监督模式下所有技能执行需要用户审批"
|
||||
}),
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Assisted mode: require approval for non-prompt skills (shell/python) that have side effects
|
||||
if autonomy_level.as_deref() != Some("autonomous") {
|
||||
let skill_id = SkillId::new(&id);
|
||||
if let Some(manifest) = kernel.skills().get_manifest(&skill_id).await {
|
||||
match manifest.mode {
|
||||
zclaw_skills::SkillMode::Shell | zclaw_skills::SkillMode::Python => {
|
||||
let approval = kernel.create_approval(id.clone(), input).await;
|
||||
return Ok(SkillResult {
|
||||
success: false,
|
||||
output: serde_json::json!({
|
||||
"status": "pending_approval",
|
||||
"approval_id": approval.id,
|
||||
"skill_id": approval.hand_id,
|
||||
"message": format!("技能 '{}' 使用 {:?} 模式,需要用户审批后执行", manifest.name, manifest.mode)
|
||||
}),
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
});
|
||||
}
|
||||
_ => {} // PromptOnly and other modes are safe to execute directly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute skill directly
|
||||
let result = kernel.execute_skill(&id, context.into(), input).await
|
||||
.map_err(|e| format!("Failed to execute skill: {}", e))?;
|
||||
|
||||
Ok(SkillResult::from(result))
|
||||
}
|
||||
242
desktop/src-tauri/src/kernel_commands/trigger.rs
Normal file
242
desktop/src-tauri/src/kernel_commands/trigger.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
//! Trigger commands: CRUD + execute
|
||||
//!
|
||||
//! Triggers are registered in the Kernel's TriggerManager.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use tauri::State;
|
||||
|
||||
use super::{validate_id, KernelState};
|
||||
|
||||
// ============================================================
|
||||
// Trigger Commands
|
||||
// ============================================================
|
||||
|
||||
/// Trigger configuration for creation/update
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TriggerConfigRequest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub hand_id: String,
|
||||
pub trigger_type: TriggerTypeRequest,
|
||||
#[serde(default = "default_trigger_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_trigger_enabled() -> bool { true }
|
||||
|
||||
/// Trigger type for API
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TriggerTypeRequest {
|
||||
Schedule { cron: String },
|
||||
Event { pattern: String },
|
||||
Webhook { path: String, secret: Option<String> },
|
||||
MessagePattern { pattern: String },
|
||||
FileSystem { path: String, events: Vec<String> },
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// Trigger response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TriggerResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub hand_id: String,
|
||||
pub trigger_type: TriggerTypeRequest,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub modified_at: String,
|
||||
pub description: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<zclaw_kernel::trigger_manager::TriggerEntry> for TriggerResponse {
|
||||
fn from(entry: zclaw_kernel::trigger_manager::TriggerEntry) -> Self {
|
||||
let trigger_type = match entry.config.trigger_type {
|
||||
zclaw_hands::TriggerType::Schedule { cron } => {
|
||||
TriggerTypeRequest::Schedule { cron }
|
||||
}
|
||||
zclaw_hands::TriggerType::Event { pattern } => {
|
||||
TriggerTypeRequest::Event { pattern }
|
||||
}
|
||||
zclaw_hands::TriggerType::Webhook { path, secret } => {
|
||||
TriggerTypeRequest::Webhook { path, secret }
|
||||
}
|
||||
zclaw_hands::TriggerType::MessagePattern { pattern } => {
|
||||
TriggerTypeRequest::MessagePattern { pattern }
|
||||
}
|
||||
zclaw_hands::TriggerType::FileSystem { path, events } => {
|
||||
TriggerTypeRequest::FileSystem {
|
||||
path,
|
||||
events: events.iter().map(|e| format!("{:?}", e).to_lowercase()).collect(),
|
||||
}
|
||||
}
|
||||
zclaw_hands::TriggerType::Manual => TriggerTypeRequest::Manual,
|
||||
};
|
||||
|
||||
Self {
|
||||
id: entry.config.id,
|
||||
name: entry.config.name,
|
||||
hand_id: entry.config.hand_id,
|
||||
trigger_type,
|
||||
enabled: entry.config.enabled,
|
||||
created_at: entry.created_at.to_rfc3339(),
|
||||
modified_at: entry.modified_at.to_rfc3339(),
|
||||
description: entry.description,
|
||||
tags: entry.tags,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all triggers
|
||||
#[tauri::command]
|
||||
pub async fn trigger_list(
|
||||
state: State<'_, KernelState>,
|
||||
) -> Result<Vec<TriggerResponse>, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let triggers = kernel.list_triggers().await;
|
||||
Ok(triggers.into_iter().map(TriggerResponse::from).collect())
|
||||
}
|
||||
|
||||
/// Get a specific trigger
|
||||
#[tauri::command]
|
||||
pub async fn trigger_get(
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
) -> Result<Option<TriggerResponse>, String> {
|
||||
// Validate trigger ID
|
||||
let id = validate_id(&id, "trigger_id")?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
Ok(kernel.get_trigger(&id).await.map(TriggerResponse::from))
|
||||
}
|
||||
|
||||
/// Create a new trigger
|
||||
#[tauri::command]
|
||||
pub async fn trigger_create(
|
||||
state: State<'_, KernelState>,
|
||||
request: TriggerConfigRequest,
|
||||
) -> Result<TriggerResponse, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
// Convert request to config
|
||||
let trigger_type = match request.trigger_type {
|
||||
TriggerTypeRequest::Schedule { cron } => {
|
||||
zclaw_hands::TriggerType::Schedule { cron }
|
||||
}
|
||||
TriggerTypeRequest::Event { pattern } => {
|
||||
zclaw_hands::TriggerType::Event { pattern }
|
||||
}
|
||||
TriggerTypeRequest::Webhook { path, secret } => {
|
||||
zclaw_hands::TriggerType::Webhook { path, secret }
|
||||
}
|
||||
TriggerTypeRequest::MessagePattern { pattern } => {
|
||||
zclaw_hands::TriggerType::MessagePattern { pattern }
|
||||
}
|
||||
TriggerTypeRequest::FileSystem { path, events } => {
|
||||
zclaw_hands::TriggerType::FileSystem {
|
||||
path,
|
||||
events: events.iter().filter_map(|e| match e.as_str() {
|
||||
"created" => Some(zclaw_hands::FileEvent::Created),
|
||||
"modified" => Some(zclaw_hands::FileEvent::Modified),
|
||||
"deleted" => Some(zclaw_hands::FileEvent::Deleted),
|
||||
"any" => Some(zclaw_hands::FileEvent::Any),
|
||||
_ => None,
|
||||
}).collect(),
|
||||
}
|
||||
}
|
||||
TriggerTypeRequest::Manual => zclaw_hands::TriggerType::Manual,
|
||||
};
|
||||
|
||||
let config = zclaw_hands::TriggerConfig {
|
||||
id: request.id,
|
||||
name: request.name,
|
||||
hand_id: request.hand_id,
|
||||
trigger_type,
|
||||
enabled: request.enabled,
|
||||
max_executions_per_hour: 10,
|
||||
};
|
||||
|
||||
let entry = kernel.create_trigger(config).await
|
||||
.map_err(|e| format!("Failed to create trigger: {}", e))?;
|
||||
|
||||
Ok(TriggerResponse::from(entry))
|
||||
}
|
||||
|
||||
/// Update a trigger
|
||||
#[tauri::command]
|
||||
pub async fn trigger_update(
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
hand_id: Option<String>,
|
||||
) -> Result<TriggerResponse, String> {
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let update = zclaw_kernel::trigger_manager::TriggerUpdateRequest {
|
||||
name,
|
||||
enabled,
|
||||
hand_id,
|
||||
trigger_type: None,
|
||||
};
|
||||
|
||||
let entry = kernel.update_trigger(&id, update).await
|
||||
.map_err(|e| format!("Failed to update trigger: {}", e))?;
|
||||
|
||||
Ok(TriggerResponse::from(entry))
|
||||
}
|
||||
|
||||
/// Delete a trigger
|
||||
#[tauri::command]
|
||||
pub async fn trigger_delete(
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
// Validate trigger ID
|
||||
let id = validate_id(&id, "trigger_id")?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
kernel.delete_trigger(&id).await
|
||||
.map_err(|e| format!("Failed to delete trigger: {}", e))
|
||||
}
|
||||
|
||||
/// Execute a trigger manually
|
||||
#[tauri::command]
|
||||
pub async fn trigger_execute(
|
||||
state: State<'_, KernelState>,
|
||||
id: String,
|
||||
input: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// Validate trigger ID
|
||||
let id = validate_id(&id, "trigger_id")?;
|
||||
|
||||
let kernel_lock = state.lock().await;
|
||||
let kernel = kernel_lock.as_ref()
|
||||
.ok_or_else(|| "Kernel not initialized".to_string())?;
|
||||
|
||||
let result = kernel.execute_trigger(&id, input).await
|
||||
.map_err(|e| format!("Failed to execute trigger: {}", e))?;
|
||||
|
||||
Ok(serde_json::to_value(result).unwrap_or(serde_json::json!({})))
|
||||
}
|
||||
Reference in New Issue
Block a user