From 59f660b93bc7eb5260f19bc9bd0099455780fa43 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 4 Apr 2026 18:41:15 +0800 Subject: [PATCH] fix(hands): add max_concurrent + timeout_secs fields + hand timeout enforcement M3-04/M3-05 audit fixes: - HandConfig: add max_concurrent (u32) and timeout_secs (u64) with serde defaults - Kernel execute_hand: enforce timeout via tokio::time::timeout, cancel on expiry - All 9 hand implementations: add max_concurrent: 0, timeout_secs: 0 - Agent createClone: pass soul field through to kernel - Fix duplicate soul block in agent_create command --- crates/zclaw-hands/src/hand.rs | 23 +++++++++++++ crates/zclaw-hands/src/hands/browser.rs | 2 ++ crates/zclaw-hands/src/hands/clip.rs | 2 ++ crates/zclaw-hands/src/hands/collector.rs | 2 ++ crates/zclaw-hands/src/hands/quiz.rs | 2 ++ crates/zclaw-hands/src/hands/researcher.rs | 2 ++ crates/zclaw-hands/src/hands/slideshow.rs | 2 ++ crates/zclaw-hands/src/hands/speech.rs | 2 ++ crates/zclaw-hands/src/hands/twitter.rs | 2 ++ crates/zclaw-hands/src/hands/whiteboard.rs | 2 ++ crates/zclaw-kernel/src/kernel/hands.rs | 32 +++++++++++++++++-- .../src-tauri/src/kernel_commands/agent.rs | 6 ++++ desktop/src/lib/kernel-agent.ts | 15 +++++++++ desktop/src/lib/kernel-types.ts | 1 + 14 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/zclaw-hands/src/hand.rs b/crates/zclaw-hands/src/hand.rs index 5a4b255..f124b5c 100644 --- a/crates/zclaw-hands/src/hand.rs +++ b/crates/zclaw-hands/src/hand.rs @@ -29,6 +29,29 @@ pub struct HandConfig { /// Whether the hand is enabled #[serde(default = "default_enabled")] pub enabled: bool, + /// Maximum concurrent executions for this hand (0 = unlimited) + #[serde(default)] + pub max_concurrent: u32, + /// Timeout in seconds for each execution (0 = use HandContext default) + #[serde(default)] + pub timeout_secs: u64, +} + +impl Default for HandConfig { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + description: String::new(), + needs_approval: false, + dependencies: Vec::new(), + input_schema: None, + tags: Vec::new(), + enabled: true, + max_concurrent: 0, + timeout_secs: 0, + } + } } fn default_enabled() -> bool { true } diff --git a/crates/zclaw-hands/src/hands/browser.rs b/crates/zclaw-hands/src/hands/browser.rs index d0dd73a..acfe8e4 100644 --- a/crates/zclaw-hands/src/hands/browser.rs +++ b/crates/zclaw-hands/src/hands/browser.rs @@ -153,6 +153,8 @@ impl BrowserHand { })), tags: vec!["automation".to_string(), "web".to_string(), "browser".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, } } diff --git a/crates/zclaw-hands/src/hands/clip.rs b/crates/zclaw-hands/src/hands/clip.rs index 9a29671..9cc02c0 100644 --- a/crates/zclaw-hands/src/hands/clip.rs +++ b/crates/zclaw-hands/src/hands/clip.rs @@ -256,6 +256,8 @@ impl ClipHand { })), tags: vec!["video".to_string(), "media".to_string(), "editing".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, ffmpeg_path: Arc::new(RwLock::new(None)), } diff --git a/crates/zclaw-hands/src/hands/collector.rs b/crates/zclaw-hands/src/hands/collector.rs index cad127a..440f87d 100644 --- a/crates/zclaw-hands/src/hands/collector.rs +++ b/crates/zclaw-hands/src/hands/collector.rs @@ -163,6 +163,8 @@ impl CollectorHand { })), tags: vec!["data".to_string(), "collection".to_string(), "scraping".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, client: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) diff --git a/crates/zclaw-hands/src/hands/quiz.rs b/crates/zclaw-hands/src/hands/quiz.rs index 0fc7efe..f792b73 100644 --- a/crates/zclaw-hands/src/hands/quiz.rs +++ b/crates/zclaw-hands/src/hands/quiz.rs @@ -492,6 +492,8 @@ impl QuizHand { })), tags: vec!["assessment".to_string(), "education".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, state: Arc::new(RwLock::new(QuizState::default())), quiz_generator: Arc::new(DefaultQuizGenerator), diff --git a/crates/zclaw-hands/src/hands/researcher.rs b/crates/zclaw-hands/src/hands/researcher.rs index c737a00..bc7c10b 100644 --- a/crates/zclaw-hands/src/hands/researcher.rs +++ b/crates/zclaw-hands/src/hands/researcher.rs @@ -183,6 +183,8 @@ impl ResearcherHand { })), tags: vec!["research".to_string(), "web".to_string(), "search".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, client: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) diff --git a/crates/zclaw-hands/src/hands/slideshow.rs b/crates/zclaw-hands/src/hands/slideshow.rs index 0408bb2..652e788 100644 --- a/crates/zclaw-hands/src/hands/slideshow.rs +++ b/crates/zclaw-hands/src/hands/slideshow.rs @@ -171,6 +171,8 @@ impl SlideshowHand { })), tags: vec!["presentation".to_string(), "education".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, state: Arc::new(RwLock::new(SlideshowState::default())), } diff --git a/crates/zclaw-hands/src/hands/speech.rs b/crates/zclaw-hands/src/hands/speech.rs index 8684c99..ee8d64c 100644 --- a/crates/zclaw-hands/src/hands/speech.rs +++ b/crates/zclaw-hands/src/hands/speech.rs @@ -164,6 +164,8 @@ impl SpeechHand { })), tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, state: Arc::new(RwLock::new(SpeechState { config: SpeechConfig::default(), diff --git a/crates/zclaw-hands/src/hands/twitter.rs b/crates/zclaw-hands/src/hands/twitter.rs index 4a2dc19..ede68b2 100644 --- a/crates/zclaw-hands/src/hands/twitter.rs +++ b/crates/zclaw-hands/src/hands/twitter.rs @@ -272,6 +272,8 @@ impl TwitterHand { })), tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string(), "demo".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, credentials: Arc::new(RwLock::new(None)), } diff --git a/crates/zclaw-hands/src/hands/whiteboard.rs b/crates/zclaw-hands/src/hands/whiteboard.rs index 5e4483f..d344f19 100644 --- a/crates/zclaw-hands/src/hands/whiteboard.rs +++ b/crates/zclaw-hands/src/hands/whiteboard.rs @@ -195,6 +195,8 @@ impl WhiteboardHand { })), tags: vec!["presentation".to_string(), "education".to_string()], enabled: true, + max_concurrent: 0, + timeout_secs: 0, }, state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState { canvas_width: 1920.0, diff --git a/crates/zclaw-kernel/src/kernel/hands.rs b/crates/zclaw-kernel/src/kernel/hands.rs index d38a324..b846f7a 100644 --- a/crates/zclaw-kernel/src/kernel/hands.rs +++ b/crates/zclaw-kernel/src/kernel/hands.rs @@ -51,12 +51,40 @@ impl Kernel { let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); self.running_hand_runs.insert(run_id, cancel_flag.clone()); - // Execute the hand + // Execute the hand (with optional timeout from HandConfig) let context = HandContext::default(); let start = std::time::Instant::now(); - let hand_result = self.hands.execute(hand_id, &context, input).await; + + // Determine timeout: prefer HandConfig.timeout_secs, fallback to context default (300s) + let timeout_secs = self.hands.get_config(hand_id) + .await + .map(|c| if c.timeout_secs > 0 { c.timeout_secs } else { context.timeout_secs }) + .unwrap_or(context.timeout_secs); + + let hand_result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + self.hands.execute(hand_id, &context, input), + ).await; + let duration = start.elapsed(); + // Handle timeout + let hand_result = match hand_result { + Ok(result) => result, + Err(_) => { + // Timeout elapsed + cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed); + let mut run_update = run.clone(); + run_update.status = HandRunStatus::Failed; + run_update.error = Some(format!("Hand execution timed out after {}s", timeout_secs)); + run_update.completed_at = Some(chrono::Utc::now().to_rfc3339()); + run_update.duration_ms = Some(duration.as_millis() as u64); + self.memory.update_hand_run(&run_update).await?; + self.running_hand_runs.remove(&run_id); + return Err(zclaw_types::ZclawError::Timeout(format!("Hand '{}' timed out after {}s", hand_id, timeout_secs))); + } + }; + // Check if cancelled during execution if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { let mut run_update = run.clone(); diff --git a/desktop/src-tauri/src/kernel_commands/agent.rs b/desktop/src-tauri/src/kernel_commands/agent.rs index 130fd91..9c2e38c 100644 --- a/desktop/src-tauri/src/kernel_commands/agent.rs +++ b/desktop/src-tauri/src/kernel_commands/agent.rs @@ -26,6 +26,8 @@ pub struct CreateAgentRequest { pub description: Option, #[serde(default)] pub system_prompt: Option, + #[serde(default)] + pub soul: Option, #[serde(default = "default_provider")] pub provider: String, #[serde(default = "default_model")] @@ -88,6 +90,10 @@ pub async fn agent_create( .with_max_tokens(request.max_tokens) .with_temperature(request.temperature); + if let Some(soul) = request.soul { + config = config.with_soul(soul); + } + if let Some(workspace) = request.workspace { config.workspace = Some(workspace); } diff --git a/desktop/src/lib/kernel-agent.ts b/desktop/src/lib/kernel-agent.ts index 8a0f184..a212611 100644 --- a/desktop/src/lib/kernel-agent.ts +++ b/desktop/src/lib/kernel-agent.ts @@ -36,6 +36,7 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v name: request.name, description: request.description, systemPrompt: request.systemPrompt, + soul: request.soul, provider: request.provider || 'anthropic', model: request.model || 'claude-sonnet-4-20250514', maxTokens: request.maxTokens || 4096, @@ -77,12 +78,24 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v model?: string; personality?: string; communicationStyle?: string; + scenarios?: string[]; + emoji?: string; + notes?: string; [key: string]: unknown; }): Promise<{ clone: any }> { + // Build soul content from personality data + const soulParts: string[] = []; + if (opts.personality) soulParts.push(`## 性格\n${opts.personality}`); + if (opts.communicationStyle) soulParts.push(`## 沟通风格\n${opts.communicationStyle}`); + if (opts.scenarios?.length) soulParts.push(`## 使用场景\n${opts.scenarios.join(', ')}`); + if (opts.notes) soulParts.push(`## 备注\n${opts.notes}`); + const soul = soulParts.length > 0 ? soulParts.join('\n\n') : undefined; + const response = await this.createAgent({ name: opts.name, description: opts.role, model: opts.model, + soul, }); const clone = { id: response.id, @@ -91,6 +104,8 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v model: opts.model, personality: opts.personality, communicationStyle: opts.communicationStyle, + emoji: opts.emoji, + scenarios: opts.scenarios, createdAt: new Date().toISOString(), }; return { clone }; diff --git a/desktop/src/lib/kernel-types.ts b/desktop/src/lib/kernel-types.ts index db4629b..366a8d5 100644 --- a/desktop/src/lib/kernel-types.ts +++ b/desktop/src/lib/kernel-types.ts @@ -32,6 +32,7 @@ export interface CreateAgentRequest { name: string; description?: string; systemPrompt?: string; + soul?: string; provider?: string; model?: string; maxTokens?: number;