fix(hands): add max_concurrent + timeout_secs fields + hand timeout enforcement
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
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
This commit is contained in:
@@ -29,6 +29,29 @@ pub struct HandConfig {
|
|||||||
/// Whether the hand is enabled
|
/// Whether the hand is enabled
|
||||||
#[serde(default = "default_enabled")]
|
#[serde(default = "default_enabled")]
|
||||||
pub enabled: bool,
|
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 }
|
fn default_enabled() -> bool { true }
|
||||||
|
|||||||
@@ -153,6 +153,8 @@ impl BrowserHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["automation".to_string(), "web".to_string(), "browser".to_string()],
|
tags: vec!["automation".to_string(), "web".to_string(), "browser".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,8 @@ impl ClipHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["video".to_string(), "media".to_string(), "editing".to_string()],
|
tags: vec!["video".to_string(), "media".to_string(), "editing".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
ffmpeg_path: Arc::new(RwLock::new(None)),
|
ffmpeg_path: Arc::new(RwLock::new(None)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ impl CollectorHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["data".to_string(), "collection".to_string(), "scraping".to_string()],
|
tags: vec!["data".to_string(), "collection".to_string(), "scraping".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
client: reqwest::Client::builder()
|
client: reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
|||||||
@@ -492,6 +492,8 @@ impl QuizHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["assessment".to_string(), "education".to_string()],
|
tags: vec!["assessment".to_string(), "education".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
state: Arc::new(RwLock::new(QuizState::default())),
|
state: Arc::new(RwLock::new(QuizState::default())),
|
||||||
quiz_generator: Arc::new(DefaultQuizGenerator),
|
quiz_generator: Arc::new(DefaultQuizGenerator),
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ impl ResearcherHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["research".to_string(), "web".to_string(), "search".to_string()],
|
tags: vec!["research".to_string(), "web".to_string(), "search".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
client: reqwest::Client::builder()
|
client: reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ impl SlideshowHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["presentation".to_string(), "education".to_string()],
|
tags: vec!["presentation".to_string(), "education".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
state: Arc::new(RwLock::new(SlideshowState::default())),
|
state: Arc::new(RwLock::new(SlideshowState::default())),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ impl SpeechHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()],
|
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
state: Arc::new(RwLock::new(SpeechState {
|
state: Arc::new(RwLock::new(SpeechState {
|
||||||
config: SpeechConfig::default(),
|
config: SpeechConfig::default(),
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ impl TwitterHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string(), "demo".to_string()],
|
tags: vec!["twitter".to_string(), "social".to_string(), "automation".to_string(), "demo".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
credentials: Arc::new(RwLock::new(None)),
|
credentials: Arc::new(RwLock::new(None)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,8 @@ impl WhiteboardHand {
|
|||||||
})),
|
})),
|
||||||
tags: vec!["presentation".to_string(), "education".to_string()],
|
tags: vec!["presentation".to_string(), "education".to_string()],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
max_concurrent: 0,
|
||||||
|
timeout_secs: 0,
|
||||||
},
|
},
|
||||||
state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState {
|
state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState {
|
||||||
canvas_width: 1920.0,
|
canvas_width: 1920.0,
|
||||||
|
|||||||
@@ -51,12 +51,40 @@ impl Kernel {
|
|||||||
let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
self.running_hand_runs.insert(run_id, cancel_flag.clone());
|
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 context = HandContext::default();
|
||||||
let start = std::time::Instant::now();
|
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();
|
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
|
// Check if cancelled during execution
|
||||||
if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
|
if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
let mut run_update = run.clone();
|
let mut run_update = run.clone();
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub struct CreateAgentRequest {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub system_prompt: Option<String>,
|
pub system_prompt: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub soul: Option<String>,
|
||||||
#[serde(default = "default_provider")]
|
#[serde(default = "default_provider")]
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
#[serde(default = "default_model")]
|
#[serde(default = "default_model")]
|
||||||
@@ -88,6 +90,10 @@ pub async fn agent_create(
|
|||||||
.with_max_tokens(request.max_tokens)
|
.with_max_tokens(request.max_tokens)
|
||||||
.with_temperature(request.temperature);
|
.with_temperature(request.temperature);
|
||||||
|
|
||||||
|
if let Some(soul) = request.soul {
|
||||||
|
config = config.with_soul(soul);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(workspace) = request.workspace {
|
if let Some(workspace) = request.workspace {
|
||||||
config.workspace = Some(workspace);
|
config.workspace = Some(workspace);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
|||||||
name: request.name,
|
name: request.name,
|
||||||
description: request.description,
|
description: request.description,
|
||||||
systemPrompt: request.systemPrompt,
|
systemPrompt: request.systemPrompt,
|
||||||
|
soul: request.soul,
|
||||||
provider: request.provider || 'anthropic',
|
provider: request.provider || 'anthropic',
|
||||||
model: request.model || 'claude-sonnet-4-20250514',
|
model: request.model || 'claude-sonnet-4-20250514',
|
||||||
maxTokens: request.maxTokens || 4096,
|
maxTokens: request.maxTokens || 4096,
|
||||||
@@ -77,12 +78,24 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
|||||||
model?: string;
|
model?: string;
|
||||||
personality?: string;
|
personality?: string;
|
||||||
communicationStyle?: string;
|
communicationStyle?: string;
|
||||||
|
scenarios?: string[];
|
||||||
|
emoji?: string;
|
||||||
|
notes?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}): Promise<{ clone: any }> {
|
}): 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({
|
const response = await this.createAgent({
|
||||||
name: opts.name,
|
name: opts.name,
|
||||||
description: opts.role,
|
description: opts.role,
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
|
soul,
|
||||||
});
|
});
|
||||||
const clone = {
|
const clone = {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -91,6 +104,8 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
|||||||
model: opts.model,
|
model: opts.model,
|
||||||
personality: opts.personality,
|
personality: opts.personality,
|
||||||
communicationStyle: opts.communicationStyle,
|
communicationStyle: opts.communicationStyle,
|
||||||
|
emoji: opts.emoji,
|
||||||
|
scenarios: opts.scenarios,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
return { clone };
|
return { clone };
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface CreateAgentRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
soul?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user