From 122d7a63beed42ff7b84c71409ec674573aad20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sun, 24 May 2026 19:28:30 +0800 Subject: [PATCH] feat(hermes): add execution limits config form --- scripts/dev-api.js | 76 ++++ src-tauri/src/commands/hermes.rs | 422 +++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/engines/hermes/pages/config.js | 156 ++++++- src/engines/hermes/style/hermes.css | 13 + src/lib/tauri-api.js | 2 + src/locales/modules/engine.js | 22 + src/main.js | 4 +- tests/hermes-config-page-ui.test.js | 25 +- tests/hermes-execution-limits-config.test.js | 121 ++++++ 10 files changed, 834 insertions(+), 9 deletions(-) create mode 100644 tests/hermes-execution-limits-config.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 72ccda9..fb25e47 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3322,6 +3322,7 @@ function normalizeHermesPlatform(platform) { const HERMES_SESSION_RESET_MODES = new Set(['both', 'idle', 'daily', 'none']) const HERMES_STREAMING_TRANSPORTS = new Set(['auto', 'draft', 'edit', 'off']) +const HERMES_CODE_EXECUTION_MODES = new Set(['project', 'strict']) const HERMES_DISPLAY_TOOL_PROGRESS_VALUES = new Set(['off', 'new', 'all', 'verbose']) const HERMES_DISPLAY_STREAMING_VALUES = new Set(['inherit', 'true', 'false']) @@ -3382,6 +3383,13 @@ function normalizeHermesStreamingTransport(value, strict = false) { return 'edit' } +function normalizeHermesCodeExecutionMode(value, strict = false) { + const mode = String(value ?? '').trim().toLowerCase() || 'project' + if (HERMES_CODE_EXECUTION_MODES.has(mode)) return mode + if (strict) throw new Error('code_execution.mode 必须是 project 或 strict') + return 'project' +} + function normalizeHermesDisplayToolProgress(value, strict = false, key = 'display.tool_progress') { const progress = String(value ?? '').trim().toLowerCase() || 'all' if (HERMES_DISPLAY_TOOL_PROGRESS_VALUES.has(progress)) return progress @@ -3585,6 +3593,53 @@ export function mergeHermesStreamingConfig(config = {}, form = {}) { return next } +export function buildHermesExecutionLimitsConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const codeExecution = root.code_execution && typeof root.code_execution === 'object' && !Array.isArray(root.code_execution) + ? root.code_execution + : {} + const delegation = root.delegation && typeof root.delegation === 'object' && !Array.isArray(root.delegation) + ? root.delegation + : {} + return { + codeExecutionMode: normalizeHermesCodeExecutionMode(codeExecution.mode, false), + codeExecutionTimeout: parseHermesInteger(codeExecution.timeout, 'code_execution.timeout', 300, 1, 86400, false), + codeExecutionMaxToolCalls: parseHermesInteger(codeExecution.max_tool_calls, 'code_execution.max_tool_calls', 50, 1, 10000, false), + delegationMaxIterations: parseHermesInteger(delegation.max_iterations, 'delegation.max_iterations', 50, 1, 1000, false), + delegationChildTimeoutSeconds: parseHermesInteger(delegation.child_timeout_seconds, 'delegation.child_timeout_seconds', 600, 30, 86400, false), + delegationMaxConcurrentChildren: parseHermesInteger(delegation.max_concurrent_children, 'delegation.max_concurrent_children', 3, 1, 100, false), + delegationMaxSpawnDepth: parseHermesInteger(delegation.max_spawn_depth, 'delegation.max_spawn_depth', 1, 1, 3, false), + delegationOrchestratorEnabled: readHermesBool(delegation.orchestrator_enabled, true), + delegationSubagentAutoApprove: readHermesBool(delegation.subagent_auto_approve, false), + delegationInheritMcpToolsets: readHermesBool(delegation.inherit_mcp_toolsets, true), + } +} + +export function mergeHermesExecutionLimitsConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesExecutionLimitsConfigValues(next) + const codeExecution = next.code_execution && typeof next.code_execution === 'object' && !Array.isArray(next.code_execution) + ? mergeConfigsPreservingFields(next.code_execution, {}) + : {} + const delegation = next.delegation && typeof next.delegation === 'object' && !Array.isArray(next.delegation) + ? mergeConfigsPreservingFields(next.delegation, {}) + : {} + + codeExecution.mode = normalizeHermesCodeExecutionMode(Object.hasOwn(form, 'codeExecutionMode') ? form.codeExecutionMode : currentValues.codeExecutionMode, true) + codeExecution.timeout = parseHermesInteger(Object.hasOwn(form, 'codeExecutionTimeout') ? form.codeExecutionTimeout : currentValues.codeExecutionTimeout, 'code_execution.timeout', 300, 1, 86400, true) + codeExecution.max_tool_calls = parseHermesInteger(Object.hasOwn(form, 'codeExecutionMaxToolCalls') ? form.codeExecutionMaxToolCalls : currentValues.codeExecutionMaxToolCalls, 'code_execution.max_tool_calls', 50, 1, 10000, true) + delegation.max_iterations = parseHermesInteger(Object.hasOwn(form, 'delegationMaxIterations') ? form.delegationMaxIterations : currentValues.delegationMaxIterations, 'delegation.max_iterations', 50, 1, 1000, true) + delegation.child_timeout_seconds = parseHermesInteger(Object.hasOwn(form, 'delegationChildTimeoutSeconds') ? form.delegationChildTimeoutSeconds : currentValues.delegationChildTimeoutSeconds, 'delegation.child_timeout_seconds', 600, 30, 86400, true) + delegation.max_concurrent_children = parseHermesInteger(Object.hasOwn(form, 'delegationMaxConcurrentChildren') ? form.delegationMaxConcurrentChildren : currentValues.delegationMaxConcurrentChildren, 'delegation.max_concurrent_children', 3, 1, 100, true) + delegation.max_spawn_depth = parseHermesInteger(Object.hasOwn(form, 'delegationMaxSpawnDepth') ? form.delegationMaxSpawnDepth : currentValues.delegationMaxSpawnDepth, 'delegation.max_spawn_depth', 1, 1, 3, true) + delegation.orchestrator_enabled = formHermesBool(form, 'delegationOrchestratorEnabled', currentValues.delegationOrchestratorEnabled) + delegation.subagent_auto_approve = formHermesBool(form, 'delegationSubagentAutoApprove', currentValues.delegationSubagentAutoApprove) + delegation.inherit_mcp_toolsets = formHermesBool(form, 'delegationInheritMcpToolsets', currentValues.delegationInheritMcpToolsets) + next.code_execution = codeExecution + next.delegation = delegation + return next +} + export function buildHermesSessionRuntimeConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const sessionReset = root.session_reset && typeof root.session_reset === 'object' && !Array.isArray(root.session_reset) @@ -9955,6 +10010,27 @@ const handlers = { } }, + hermes_execution_limits_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesExecutionLimitsConfigValues(config), + } + }, + + hermes_execution_limits_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesExecutionLimitsConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesExecutionLimitsConfigValues(next), + } + }, + // P1-3 lazy_deps: Web 模式下不能调 venv python,但仍提供 feature 列表 + 提示用户走桌面端装 hermes_lazy_deps_features() { const features = [ diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 3fe7ac3..d6cff32 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3672,6 +3672,26 @@ fn normalize_hermes_streaming_transport( } } +fn normalize_hermes_code_execution_mode( + value: Option, + strict: bool, +) -> Result { + let mode = value.unwrap_or_default().trim().to_ascii_lowercase(); + let mode = if mode.is_empty() { + "project".to_string() + } else { + mode + }; + if matches!(mode.as_str(), "project" | "strict") { + return Ok(mode); + } + if strict { + Err("code_execution.mode 必须是 project 或 strict".to_string()) + } else { + Ok("project".to_string()) + } +} + fn hermes_streaming_config_source(config: &serde_yaml::Value) -> Option<&serde_yaml::Mapping> { let root = config.as_mapping()?; if let Some(streaming) = yaml_get_mapping(root, "streaming") { @@ -3795,6 +3815,212 @@ fn merge_hermes_streaming_config( Ok(()) } +fn build_hermes_execution_limits_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let code_execution = root.and_then(|map| yaml_get_mapping(map, "code_execution")); + let delegation = root.and_then(|map| yaml_get_mapping(map, "delegation")); + let code_execution_mode = normalize_hermes_code_execution_mode( + code_execution.and_then(|map| yaml_string_field(map, "mode")), + false, + ) + .unwrap_or_else(|_| "project".to_string()); + let code_execution_timeout = code_execution + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "timeout"), 300, 1, 86400)) + .unwrap_or(300); + let code_execution_max_tool_calls = code_execution + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_tool_calls"), 50, 1, 10000)) + .unwrap_or(50); + let delegation_max_iterations = delegation + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_iterations"), 50, 1, 1000)) + .unwrap_or(50); + let delegation_child_timeout_seconds = delegation + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "child_timeout_seconds"), 600, 30, 86400)) + .unwrap_or(600); + let delegation_max_concurrent_children = delegation + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_concurrent_children"), 3, 1, 100)) + .unwrap_or(3); + let delegation_max_spawn_depth = delegation + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_spawn_depth"), 1, 1, 3)) + .unwrap_or(1); + let delegation_orchestrator_enabled = delegation + .and_then(|map| yaml_bool_field(map, "orchestrator_enabled")) + .unwrap_or(true); + let delegation_subagent_auto_approve = delegation + .and_then(|map| yaml_bool_field(map, "subagent_auto_approve")) + .unwrap_or(false); + let delegation_inherit_mcp_toolsets = delegation + .and_then(|map| yaml_bool_field(map, "inherit_mcp_toolsets")) + .unwrap_or(true); + + serde_json::json!({ + "codeExecutionMode": code_execution_mode, + "codeExecutionTimeout": code_execution_timeout, + "codeExecutionMaxToolCalls": code_execution_max_tool_calls, + "delegationMaxIterations": delegation_max_iterations, + "delegationChildTimeoutSeconds": delegation_child_timeout_seconds, + "delegationMaxConcurrentChildren": delegation_max_concurrent_children, + "delegationMaxSpawnDepth": delegation_max_spawn_depth, + "delegationOrchestratorEnabled": delegation_orchestrator_enabled, + "delegationSubagentAutoApprove": delegation_subagent_auto_approve, + "delegationInheritMcpToolsets": delegation_inherit_mcp_toolsets, + }) +} + +fn merge_hermes_execution_limits_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_execution_limits_config_values(config); + let code_execution_mode = normalize_hermes_code_execution_mode( + if form.get("codeExecutionMode").is_some() { + form_string(form, "codeExecutionMode") + } else { + current["codeExecutionMode"] + .as_str() + .map(ToString::to_string) + }, + true, + )?; + let code_execution_timeout = validate_hermes_i64( + if form.get("codeExecutionTimeout").is_some() { + form_i64(form, "codeExecutionTimeout") + } else { + Some(current["codeExecutionTimeout"].as_i64().unwrap_or(300)) + }, + "code_execution.timeout", + 300, + 1, + 86400, + )?; + let code_execution_max_tool_calls = validate_hermes_i64( + if form.get("codeExecutionMaxToolCalls").is_some() { + form_i64(form, "codeExecutionMaxToolCalls") + } else { + Some(current["codeExecutionMaxToolCalls"].as_i64().unwrap_or(50)) + }, + "code_execution.max_tool_calls", + 50, + 1, + 10000, + )?; + let delegation_max_iterations = validate_hermes_i64( + if form.get("delegationMaxIterations").is_some() { + form_i64(form, "delegationMaxIterations") + } else { + Some(current["delegationMaxIterations"].as_i64().unwrap_or(50)) + }, + "delegation.max_iterations", + 50, + 1, + 1000, + )?; + let delegation_child_timeout_seconds = validate_hermes_i64( + if form.get("delegationChildTimeoutSeconds").is_some() { + form_i64(form, "delegationChildTimeoutSeconds") + } else { + Some( + current["delegationChildTimeoutSeconds"] + .as_i64() + .unwrap_or(600), + ) + }, + "delegation.child_timeout_seconds", + 600, + 30, + 86400, + )?; + let delegation_max_concurrent_children = validate_hermes_i64( + if form.get("delegationMaxConcurrentChildren").is_some() { + form_i64(form, "delegationMaxConcurrentChildren") + } else { + Some( + current["delegationMaxConcurrentChildren"] + .as_i64() + .unwrap_or(3), + ) + }, + "delegation.max_concurrent_children", + 3, + 1, + 100, + )?; + let delegation_max_spawn_depth = validate_hermes_i64( + if form.get("delegationMaxSpawnDepth").is_some() { + form_i64(form, "delegationMaxSpawnDepth") + } else { + Some(current["delegationMaxSpawnDepth"].as_i64().unwrap_or(1)) + }, + "delegation.max_spawn_depth", + 1, + 1, + 3, + )?; + let delegation_orchestrator_enabled = form_bool(form, "delegationOrchestratorEnabled") + .unwrap_or_else(|| { + current["delegationOrchestratorEnabled"] + .as_bool() + .unwrap_or(true) + }); + let delegation_subagent_auto_approve = form_bool(form, "delegationSubagentAutoApprove") + .unwrap_or_else(|| { + current["delegationSubagentAutoApprove"] + .as_bool() + .unwrap_or(false) + }); + let delegation_inherit_mcp_toolsets = form_bool(form, "delegationInheritMcpToolsets") + .unwrap_or_else(|| { + current["delegationInheritMcpToolsets"] + .as_bool() + .unwrap_or(true) + }); + + let root = ensure_yaml_object(config)?; + let code_execution = yaml_child_object(root, "code_execution")?; + code_execution.insert( + yaml_key("mode"), + serde_yaml::Value::String(code_execution_mode), + ); + code_execution.insert( + yaml_key("timeout"), + serde_yaml::Value::Number(code_execution_timeout.into()), + ); + code_execution.insert( + yaml_key("max_tool_calls"), + serde_yaml::Value::Number(code_execution_max_tool_calls.into()), + ); + + let delegation = yaml_child_object(root, "delegation")?; + delegation.insert( + yaml_key("max_iterations"), + serde_yaml::Value::Number(delegation_max_iterations.into()), + ); + delegation.insert( + yaml_key("child_timeout_seconds"), + serde_yaml::Value::Number(delegation_child_timeout_seconds.into()), + ); + delegation.insert( + yaml_key("max_concurrent_children"), + serde_yaml::Value::Number(delegation_max_concurrent_children.into()), + ); + delegation.insert( + yaml_key("max_spawn_depth"), + serde_yaml::Value::Number(delegation_max_spawn_depth.into()), + ); + delegation.insert( + yaml_key("orchestrator_enabled"), + serde_yaml::Value::Bool(delegation_orchestrator_enabled), + ); + delegation.insert( + yaml_key("subagent_auto_approve"), + serde_yaml::Value::Bool(delegation_subagent_auto_approve), + ); + delegation.insert( + yaml_key("inherit_mcp_toolsets"), + serde_yaml::Value::Bool(delegation_inherit_mcp_toolsets), + ); + Ok(()) +} + fn build_hermes_session_runtime_config_values(config: &serde_yaml::Value) -> Value { let root = config.as_mapping(); let session_reset = root.and_then(|map| yaml_get_mapping(map, "session_reset")); @@ -4702,6 +4928,30 @@ pub fn hermes_streaming_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_execution_limits_config_read() -> Result { + let (config_path, exists, config) = read_hermes_channel_yaml_config()?; + ensure_yaml_object(&mut config.clone())?; + Ok(serde_json::json!({ + "exists": exists, + "configPath": config_path.to_string_lossy(), + "values": build_hermes_execution_limits_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_execution_limits_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_execution_limits_config(&mut config, &form)?; + let backup = write_hermes_yaml_config(&config_path, &config)?; + Ok(serde_json::json!({ + "ok": true, + "configPath": config_path.to_string_lossy(), + "backup": backup, + "values": build_hermes_execution_limits_config_values(&config), + })) +} + // --------------------------------------------------------------------------- // hermes_read_config — 读取 Hermes config.yaml + .env // --------------------------------------------------------------------------- @@ -10022,6 +10272,178 @@ display: } } +#[cfg(test)] +mod hermes_execution_limits_config_tests { + use super::{ + build_hermes_execution_limits_config_values, merge_hermes_execution_limits_config, + }; + use serde_json::json; + + #[test] + fn execution_limits_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_execution_limits_config_values(&config); + assert_eq!(values["codeExecutionMode"], "project"); + assert_eq!(values["codeExecutionTimeout"], 300); + assert_eq!(values["codeExecutionMaxToolCalls"], 50); + assert_eq!(values["delegationMaxIterations"], 50); + assert_eq!(values["delegationChildTimeoutSeconds"], 600); + assert_eq!(values["delegationMaxConcurrentChildren"], 3); + assert_eq!(values["delegationMaxSpawnDepth"], 1); + assert_eq!(values["delegationOrchestratorEnabled"], true); + assert_eq!(values["delegationSubagentAutoApprove"], false); + assert_eq!(values["delegationInheritMcpToolsets"], true); + } + + #[test] + fn execution_limits_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +code_execution: + mode: strict + timeout: 120 + max_tool_calls: 12 +delegation: + max_iterations: 30 + child_timeout_seconds: 900 + max_concurrent_children: 5 + max_spawn_depth: 2 + orchestrator_enabled: false + subagent_auto_approve: true + inherit_mcp_toolsets: false +"#, + ) + .unwrap(); + let values = build_hermes_execution_limits_config_values(&config); + assert_eq!(values["codeExecutionMode"], "strict"); + assert_eq!(values["codeExecutionTimeout"], 120); + assert_eq!(values["codeExecutionMaxToolCalls"], 12); + assert_eq!(values["delegationMaxIterations"], 30); + assert_eq!(values["delegationChildTimeoutSeconds"], 900); + assert_eq!(values["delegationMaxConcurrentChildren"], 5); + assert_eq!(values["delegationMaxSpawnDepth"], 2); + assert_eq!(values["delegationOrchestratorEnabled"], false); + assert_eq!(values["delegationSubagentAutoApprove"], true); + assert_eq!(values["delegationInheritMcpToolsets"], false); + } + + #[test] + fn merge_execution_limits_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +code_execution: + mode: project + custom_flag: keep-code +delegation: + model: child-model + provider: openrouter + custom_flag: keep-delegation +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_execution_limits_config( + &mut config, + &json!({ + "codeExecutionMode": "strict", + "codeExecutionTimeout": "180", + "codeExecutionMaxToolCalls": "25", + "delegationMaxIterations": "40", + "delegationChildTimeoutSeconds": "1200", + "delegationMaxConcurrentChildren": "4", + "delegationMaxSpawnDepth": "2", + "delegationOrchestratorEnabled": false, + "delegationSubagentAutoApprove": true, + "delegationInheritMcpToolsets": false, + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["code_execution"]["mode"].as_str(), Some("strict")); + assert_eq!(config["code_execution"]["timeout"].as_i64(), Some(180)); + assert_eq!( + config["code_execution"]["max_tool_calls"].as_i64(), + Some(25) + ); + assert_eq!( + config["code_execution"]["custom_flag"].as_str(), + Some("keep-code") + ); + assert_eq!(config["delegation"]["max_iterations"].as_i64(), Some(40)); + assert_eq!( + config["delegation"]["child_timeout_seconds"].as_i64(), + Some(1200) + ); + assert_eq!( + config["delegation"]["max_concurrent_children"].as_i64(), + Some(4) + ); + assert_eq!(config["delegation"]["max_spawn_depth"].as_i64(), Some(2)); + assert_eq!( + config["delegation"]["orchestrator_enabled"].as_bool(), + Some(false) + ); + assert_eq!( + config["delegation"]["subagent_auto_approve"].as_bool(), + Some(true) + ); + assert_eq!( + config["delegation"]["inherit_mcp_toolsets"].as_bool(), + Some(false) + ); + assert_eq!(config["delegation"]["model"].as_str(), Some("child-model")); + assert_eq!( + config["delegation"]["provider"].as_str(), + Some("openrouter") + ); + assert_eq!( + config["delegation"]["custom_flag"].as_str(), + Some("keep-delegation") + ); + } + + #[test] + fn merge_execution_limits_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_execution_limits_config( + &mut config, + &json!({ "codeExecutionMode": "unsafe" }), + ) + .unwrap_err(); + assert!(err.contains("code_execution.mode")); + let err = merge_hermes_execution_limits_config( + &mut config, + &json!({ "codeExecutionTimeout": 0 }), + ) + .unwrap_err(); + assert!(err.contains("code_execution.timeout")); + let err = merge_hermes_execution_limits_config( + &mut config, + &json!({ "delegationMaxConcurrentChildren": 0 }), + ) + .unwrap_err(); + assert!(err.contains("delegation.max_concurrent_children")); + let err = merge_hermes_execution_limits_config( + &mut config, + &json!({ "delegationMaxSpawnDepth": 4 }), + ) + .unwrap_err(); + assert!(err.contains("delegation.max_spawn_depth")); + let err = merge_hermes_execution_limits_config( + &mut config, + &json!({ "delegationChildTimeoutSeconds": 29 }), + ) + .unwrap_err(); + assert!(err.contains("delegation.child_timeout_seconds")); + } +} + #[cfg(test)] mod hermes_memory_config_tests { use super::{build_hermes_memory_config_values, merge_hermes_memory_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 299cf69..66a3859 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -267,6 +267,8 @@ pub fn run() { hermes::hermes_memory_config_save, hermes::hermes_streaming_config_read, hermes::hermes_streaming_config_save, + hermes::hermes_execution_limits_config_read, + hermes::hermes_execution_limits_config_save, hermes::hermes_lazy_deps_features, hermes::hermes_lazy_deps_status, hermes::hermes_lazy_deps_ensure, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index d0fc831..7149021 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -51,8 +51,22 @@ const STREAMING_DEFAULTS = { freshFinalAfterSeconds: 60, } +const EXECUTION_LIMITS_DEFAULTS = { + codeExecutionMode: 'project', + codeExecutionTimeout: 300, + codeExecutionMaxToolCalls: 50, + delegationMaxIterations: 50, + delegationChildTimeoutSeconds: 600, + delegationMaxConcurrentChildren: 3, + delegationMaxSpawnDepth: 1, + delegationOrchestratorEnabled: true, + delegationSubagentAutoApprove: false, + delegationInheritMcpToolsets: true, +} + const SESSION_RESET_MODES = ['both', 'idle', 'daily', 'none'] const STREAMING_TRANSPORTS = ['edit', 'auto', 'draft', 'off'] +const CODE_EXECUTION_MODES = ['project', 'strict'] export function render() { const el = document.createElement('div') @@ -64,24 +78,28 @@ export function render() { let toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS } let memoryValues = { ...MEMORY_DEFAULTS } let streamingValues = { ...STREAMING_DEFAULTS } + let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } let loading = true let runtimeLoading = true let compressionLoading = true let toolGuardrailsLoading = true let memoryLoading = true let streamingLoading = true + let executionLimitsLoading = true let saving = false let runtimeSaving = false let compressionSaving = false let toolGuardrailsSaving = false let memorySaving = false let streamingSaving = false + let executionLimitsSaving = false let error = null let runtimeError = null let compressionError = null let toolGuardrailsError = null let memoryError = null let streamingError = null + let executionLimitsError = null function esc(value) { return String(value ?? '') @@ -92,7 +110,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || streamingLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || streamingLoading || executionLimitsLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving } function option(labelKey, value, selected) { @@ -109,7 +127,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving return `
@@ -157,7 +175,7 @@ export function render() { } function renderCompressionPanel() { - const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || streamingSaving + const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving return `
@@ -207,7 +225,7 @@ export function render() { } function renderToolGuardrailsPanel() { - const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || streamingSaving + const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || streamingSaving || executionLimitsSaving return `
@@ -269,7 +287,7 @@ export function render() { } function renderMemoryPanel() { - const disabled = loading || saving || memoryLoading || memorySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving + const disabled = loading || saving || memoryLoading || memorySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving return `
@@ -315,7 +333,7 @@ export function render() { } function renderStreamingPanel() { - const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving + const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || executionLimitsSaving return `
@@ -366,6 +384,78 @@ export function render() { ` } + function renderExecutionLimitsPanel() { + const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving + return ` +
+
+
+
${t('engine.hermesExecutionLimitsTitle')}
+
${t('engine.hermesExecutionLimitsDesc')}
+
+
+ ${executionLimitsSaving ? t('engine.hermesConfigStatusSaving') : executionLimitsLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesExecutionLimitsStatusReady')} + +
+
+
+ ${renderError(executionLimitsError)} +
${t('engine.hermesExecutionLimitsCodeTitle')}
+
+ + + +
+
${t('engine.hermesExecutionLimitsDelegationTitle')}
+
+ + + + +
+
+ + + +
+
${t('engine.hermesExecutionLimitsFootnote')}
+
+
+ ` + } + function draw() { el.innerHTML = `
@@ -382,6 +472,7 @@ export function render() { ${renderRuntimePanel()} ${renderStreamingPanel()} + ${renderExecutionLimitsPanel()} ${renderCompressionPanel()} ${renderToolGuardrailsPanel()} ${renderMemoryPanel()} @@ -409,6 +500,7 @@ export function render() { el.querySelector('#hm-tool-guardrails-save')?.addEventListener('click', saveToolGuardrails) el.querySelector('#hm-memory-save')?.addEventListener('click', saveMemory) el.querySelector('#hm-streaming-save')?.addEventListener('click', saveStreaming) + el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits) } async function loadRaw() { @@ -441,6 +533,11 @@ export function render() { streamingValues = { ...STREAMING_DEFAULTS, ...(data?.values || {}) } } + async function loadExecutionLimits() { + const data = await api.hermesExecutionLimitsConfigRead() + executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS, ...(data?.values || {}) } + } + async function load() { loading = true runtimeLoading = true @@ -448,12 +545,14 @@ export function render() { toolGuardrailsLoading = true memoryLoading = true streamingLoading = true + executionLimitsLoading = true error = null runtimeError = null compressionError = null toolGuardrailsError = null memoryError = null streamingError = null + executionLimitsError = null draw() try { await loadRaw() @@ -494,6 +593,14 @@ export function render() { streamingLoading = false draw() } + try { + await loadExecutionLimits() + } catch (err) { + executionLimitsError = humanizeError(err, t('engine.hermesExecutionLimitsLoadFailed') || 'Load execution limit config failed') + } finally { + executionLimitsLoading = false + draw() + } try { await loadMemory() } catch (err) { @@ -538,6 +645,9 @@ export function render() { try { await loadStreaming() } catch {} + try { + await loadExecutionLimits() + } catch {} } catch (err) { error = humanizeError(err, t('engine.hermesConfigSaveFailed') || 'Save failed') toast(error, 'error') @@ -697,6 +807,40 @@ export function render() { } } + async function saveExecutionLimits() { + const form = { + codeExecutionMode: el.querySelector('#hm-code-execution-mode')?.value || 'project', + codeExecutionTimeout: el.querySelector('#hm-code-execution-timeout')?.value || '300', + codeExecutionMaxToolCalls: el.querySelector('#hm-code-execution-max-tool-calls')?.value || '50', + delegationMaxIterations: el.querySelector('#hm-delegation-max-iterations')?.value || '50', + delegationChildTimeoutSeconds: el.querySelector('#hm-delegation-child-timeout-seconds')?.value || '600', + delegationMaxConcurrentChildren: el.querySelector('#hm-delegation-max-concurrent-children')?.value || '3', + delegationMaxSpawnDepth: el.querySelector('#hm-delegation-max-spawn-depth')?.value || '1', + delegationOrchestratorEnabled: !!el.querySelector('#hm-delegation-orchestrator-enabled')?.checked, + delegationSubagentAutoApprove: !!el.querySelector('#hm-delegation-subagent-auto-approve')?.checked, + delegationInheritMcpToolsets: !!el.querySelector('#hm-delegation-inherit-mcp-toolsets')?.checked, + } + executionLimitsSaving = true + executionLimitsError = null + draw() + try { + const result = await api.hermesExecutionLimitsConfigSave(form) + executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesExecutionLimitsSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + executionLimitsError = humanizeError(err, t('engine.hermesExecutionLimitsSaveFailed') || 'Save execution limit config failed') + toast(executionLimitsError, 'error') + } finally { + executionLimitsSaving = false + draw() + } + } + draw() load() return el diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 3f6892f..40bbab3 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -6769,6 +6769,14 @@ body[data-active-engine="hermes"][data-theme="dark"] { grid-template-columns: repeat(5, minmax(140px, 1fr)); margin-top: 18px; } +[data-engine="hermes"] .hm-config-execution-grid { + grid-template-columns: minmax(180px, 1.2fr) repeat(2, minmax(150px, 1fr)); + margin-top: 12px; +} +[data-engine="hermes"] .hm-config-delegation-grid { + grid-template-columns: repeat(4, minmax(150px, 1fr)); + margin-top: 12px; +} [data-engine="hermes"] .hm-config-subtitle { margin-top: 20px; color: var(--hm-text-secondary); @@ -6834,6 +6842,9 @@ body[data-active-engine="hermes"][data-theme="dark"] { accent-color: var(--hm-accent); flex: 0 0 auto; } +[data-engine="hermes"] .hm-channel-check--danger span { + color: var(--hm-warning, #a16207); +} [data-engine="hermes"] .hm-channel-section { display: grid; gap: 16px; @@ -6910,6 +6921,8 @@ body[data-active-engine="hermes"][data-theme="dark"] { [data-engine="hermes"] .hm-config-guardrails-grid, [data-engine="hermes"] .hm-config-memory-grid, [data-engine="hermes"] .hm-config-streaming-grid, + [data-engine="hermes"] .hm-config-execution-grid, + [data-engine="hermes"] .hm-config-delegation-grid, [data-engine="hermes"] .hm-config-check-grid { grid-template-columns: 1fr; } diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index b03a926..7f4b83a 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -519,6 +519,8 @@ export const api = { hermesMemoryConfigSave: (form) => invoke('hermes_memory_config_save', { form }), hermesStreamingConfigRead: () => invoke('hermes_streaming_config_read'), hermesStreamingConfigSave: (form) => invoke('hermes_streaming_config_save', { form }), + hermesExecutionLimitsConfigRead: () => invoke('hermes_execution_limits_config_read'), + hermesExecutionLimitsConfigSave: (form) => invoke('hermes_execution_limits_config_save', { form }), hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000), hermesLazyDepsStatus: (features) => invoke('hermes_lazy_deps_status', { features }), hermesLazyDepsEnsure: (feature) => invoke('hermes_lazy_deps_ensure', { feature }), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 2939077..5bb00b0 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -516,6 +516,28 @@ export default { hermesStreamingConfigFreshFinalAfterSeconds: _('长回复完成新消息时间(秒)', 'Fresh final message after (s)', '長回覆完成新訊息時間(秒)'), hermesStreamingConfigCursor: _('生成中标记', 'In-progress marker', '生成中標記'), hermesStreamingConfigFootnote: _('这里写入顶层 streaming 配置;旧版 gateway.streaming、display.streaming 和其他高级字段会保留在 raw YAML 中。将“长回复完成新消息时间”设为 0 可关闭完成后新消息。', 'This writes the top-level streaming settings. Legacy gateway.streaming, display.streaming, and other advanced fields are preserved in raw YAML. Set fresh final message time to 0 to disable the final follow-up message.', '這裡寫入頂層 streaming 設定;舊版 gateway.streaming、display.streaming 和其他進階欄位會保留在 raw YAML 中。將「長回覆完成新訊息時間」設為 0 可關閉完成後新訊息。'), + hermesExecutionLimitsTitle: _('执行与委派限制', 'Execution and delegation limits', '執行與委派限制'), + hermesExecutionLimitsDesc: _('控制 execute_code 沙箱和 delegate_task 子 Agent 的超时、并发、深度与自动批准策略,降低长跑任务失控和成本放大的风险。', 'Control execute_code sandbox and delegate_task child-agent timeouts, concurrency, depth, and auto-approval policy to reduce runaway long-run tasks and cost amplification.', '控制 execute_code 沙箱和 delegate_task 子 Agent 的逾時、並發、深度與自動批准策略,降低長跑任務失控和成本放大的風險。'), + hermesExecutionLimitsStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesExecutionLimitsSave: _('保存执行限制', 'Save execution limits', '儲存執行限制'), + hermesExecutionLimitsSaveSuccess: _('执行与委派限制已保存,建议重启 Hermes Gateway 生效', 'Execution and delegation limits saved. Restart Hermes Gateway to take effect.', '執行與委派限制已儲存,建議重啟 Hermes Gateway 生效'), + hermesExecutionLimitsLoadFailed: _('加载执行与委派限制失败', 'Load execution and delegation limits failed', '載入執行與委派限制失敗'), + hermesExecutionLimitsSaveFailed: _('保存执行与委派限制失败', 'Save execution and delegation limits failed', '儲存執行與委派限制失敗'), + hermesExecutionLimitsCodeTitle: _('代码执行沙箱', 'Code execution sandbox', '程式碼執行沙箱'), + hermesExecutionLimitsCodeMode: _('执行模式', 'Execution mode', '執行模式'), + hermesExecutionLimitsCodeMode_project: _('项目环境', 'Project environment', '專案環境'), + hermesExecutionLimitsCodeMode_strict: _('严格隔离', 'Strict isolation', '嚴格隔離'), + hermesExecutionLimitsCodeTimeout: _('脚本超时(秒)', 'Script timeout (s)', '腳本逾時(秒)'), + hermesExecutionLimitsCodeMaxToolCalls: _('最大工具调用数', 'Max tool calls', '最大工具呼叫數'), + hermesExecutionLimitsDelegationTitle: _('子 Agent 委派', 'Child-agent delegation', '子 Agent 委派'), + hermesExecutionLimitsDelegationMaxIterations: _('每个子任务最大轮数', 'Max turns per child', '每個子任務最大輪數'), + hermesExecutionLimitsDelegationChildTimeout: _('每个子任务超时(秒)', 'Child timeout (s)', '每個子任務逾時(秒)'), + hermesExecutionLimitsDelegationMaxConcurrent: _('最大并发子任务', 'Max concurrent children', '最大並發子任務'), + hermesExecutionLimitsDelegationMaxSpawnDepth: _('委派深度上限', 'Spawn depth limit', '委派深度上限'), + hermesExecutionLimitsDelegationOrchestratorEnabled: _('允许中间协调 Agent', 'Allow orchestrator children', '允許中間協調 Agent'), + hermesExecutionLimitsDelegationInheritMcp: _('保留父任务 MCP 工具集', 'Inherit parent MCP toolsets', '保留父任務 MCP 工具集'), + hermesExecutionLimitsDelegationAutoApprove: _('自动批准子任务危险命令', 'Auto-approve child dangerous commands', '自動批准子任務危險命令'), + hermesExecutionLimitsFootnote: _('默认会拒绝子任务危险命令审批,适合交互式和长跑任务。只有在完全信任无人值守环境时才开启自动批准。', 'By default, dangerous-command approvals from child agents are auto-denied, which fits interactive and long-running tasks. Enable auto-approval only in fully trusted unattended environments.', '預設會拒絕子任務危險命令審批,適合互動式和長跑任務。只有在完全信任無人值守環境時才啟用自動批准。'), hermesCompressionTitle: _('上下文压缩', 'Context compression', '上下文壓縮'), hermesCompressionDesc: _('控制长对话何时触发压缩、压缩目标和保留范围,降低上下文过长导致的失败与费用浪费。', 'Control when long conversations are compressed, the target size, and protected message ranges to reduce failures and wasted cost from oversized context.', '控制長對話何時觸發壓縮、壓縮目標和保留範圍,降低上下文過長導致的失敗與費用浪費。'), hermesCompressionStatusReady: _('结构化配置', 'structured settings', '結構化設定'), diff --git a/src/main.js b/src/main.js index 46795e4..6a36deb 100644 --- a/src/main.js +++ b/src/main.js @@ -404,8 +404,8 @@ async function boot() { banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;padding:10px 20px;display:flex;align-items:center;justify-content:center;gap:12px;font-size:13px;font-weight:500;box-shadow:0 2px 8px rgba(0,0,0,0.15)' banner.innerHTML = ` ${statusIcon('warn', 14)} ${t('common.defaultPasswordBanner')} - ${t('common.goSecurity')} - + ${t('common.goSecurity')} + ` document.body.prepend(banner) } diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 534cfdc..2580364 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -52,12 +52,35 @@ test('Hermes 配置页会暴露网关流式结构化配置字段', () => { } }) +test('Hermes 配置页会暴露执行与委派限制结构化配置字段', () => { + for (const id of [ + 'hm-execution-limits-save', + 'hm-code-execution-mode', + 'hm-code-execution-timeout', + 'hm-code-execution-max-tool-calls', + 'hm-delegation-max-iterations', + 'hm-delegation-child-timeout-seconds', + 'hm-delegation-max-concurrent-children', + 'hm-delegation-max-spawn-depth', + 'hm-delegation-orchestrator-enabled', + 'hm-delegation-subagent-auto-approve', + 'hm-delegation-inherit-mcp-toolsets', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页数值输入会保留 0 值显示', () => { assert.doesNotMatch(source, /String\(value \|\| ''\)/, 'esc(value) 不能把合法 0 渲染为空字符串') }) test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { - const keys = new Set(extractEngineKeys().filter(key => key.includes('ToolGuardrails') || key.includes('MemoryConfig') || key.includes('StreamingConfig'))) + const keys = new Set(extractEngineKeys().filter(key => ( + key.includes('ToolGuardrails') || + key.includes('MemoryConfig') || + key.includes('StreamingConfig') || + key.includes('ExecutionLimits') + ))) assert.ok(keys.size > 0, '应能提取新增结构化配置用到的 engine 翻译 key') for (const key of keys) { diff --git a/tests/hermes-execution-limits-config.test.js b/tests/hermes-execution-limits-config.test.js new file mode 100644 index 0000000..88232cb --- /dev/null +++ b/tests/hermes-execution-limits-config.test.js @@ -0,0 +1,121 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesExecutionLimitsConfigValues, + mergeHermesExecutionLimitsConfig, +} from '../scripts/dev-api.js' + +test('Hermes 执行与委派限制读取会提供上游默认值', () => { + const values = buildHermesExecutionLimitsConfigValues({}) + + assert.deepEqual(values, { + codeExecutionMode: 'project', + codeExecutionTimeout: 300, + codeExecutionMaxToolCalls: 50, + delegationMaxIterations: 50, + delegationChildTimeoutSeconds: 600, + delegationMaxConcurrentChildren: 3, + delegationMaxSpawnDepth: 1, + delegationOrchestratorEnabled: true, + delegationSubagentAutoApprove: false, + delegationInheritMcpToolsets: true, + }) +}) + +test('Hermes 执行与委派限制读取会回显 YAML 字段', () => { + const values = buildHermesExecutionLimitsConfigValues({ + code_execution: { + mode: 'strict', + timeout: 120, + max_tool_calls: 12, + }, + delegation: { + max_iterations: 30, + child_timeout_seconds: 900, + max_concurrent_children: 5, + max_spawn_depth: 2, + orchestrator_enabled: false, + subagent_auto_approve: true, + inherit_mcp_toolsets: false, + }, + }) + + assert.equal(values.codeExecutionMode, 'strict') + assert.equal(values.codeExecutionTimeout, 120) + assert.equal(values.codeExecutionMaxToolCalls, 12) + assert.equal(values.delegationMaxIterations, 30) + assert.equal(values.delegationChildTimeoutSeconds, 900) + assert.equal(values.delegationMaxConcurrentChildren, 5) + assert.equal(values.delegationMaxSpawnDepth, 2) + assert.equal(values.delegationOrchestratorEnabled, false) + assert.equal(values.delegationSubagentAutoApprove, true) + assert.equal(values.delegationInheritMcpToolsets, false) +}) + +test('Hermes 执行与委派限制保存会保留未知字段并写入上游结构', () => { + const next = mergeHermesExecutionLimitsConfig({ + model: { provider: 'anthropic' }, + code_execution: { + mode: 'project', + custom_flag: 'keep-code', + }, + delegation: { + model: 'child-model', + provider: 'openrouter', + custom_flag: 'keep-delegation', + }, + streaming: { enabled: true }, + }, { + codeExecutionMode: 'strict', + codeExecutionTimeout: '180', + codeExecutionMaxToolCalls: '25', + delegationMaxIterations: '40', + delegationChildTimeoutSeconds: '1200', + delegationMaxConcurrentChildren: '4', + delegationMaxSpawnDepth: '2', + delegationOrchestratorEnabled: false, + delegationSubagentAutoApprove: true, + delegationInheritMcpToolsets: false, + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.code_execution.mode, 'strict') + assert.equal(next.code_execution.timeout, 180) + assert.equal(next.code_execution.max_tool_calls, 25) + assert.equal(next.code_execution.custom_flag, 'keep-code') + assert.equal(next.delegation.max_iterations, 40) + assert.equal(next.delegation.child_timeout_seconds, 1200) + assert.equal(next.delegation.max_concurrent_children, 4) + assert.equal(next.delegation.max_spawn_depth, 2) + assert.equal(next.delegation.orchestrator_enabled, false) + assert.equal(next.delegation.subagent_auto_approve, true) + assert.equal(next.delegation.inherit_mcp_toolsets, false) + assert.equal(next.delegation.model, 'child-model') + assert.equal(next.delegation.provider, 'openrouter') + assert.equal(next.delegation.custom_flag, 'keep-delegation') +}) + +test('Hermes 执行与委派限制保存会拒绝非法模式和越界值', () => { + assert.throws( + () => mergeHermesExecutionLimitsConfig({}, { codeExecutionMode: 'unsafe' }), + /code_execution\.mode/, + ) + assert.throws( + () => mergeHermesExecutionLimitsConfig({}, { codeExecutionTimeout: '0' }), + /code_execution\.timeout/, + ) + assert.throws( + () => mergeHermesExecutionLimitsConfig({}, { delegationMaxConcurrentChildren: '0' }), + /delegation\.max_concurrent_children/, + ) + assert.throws( + () => mergeHermesExecutionLimitsConfig({}, { delegationMaxSpawnDepth: '4' }), + /delegation\.max_spawn_depth/, + ) + assert.throws( + () => mergeHermesExecutionLimitsConfig({}, { delegationChildTimeoutSeconds: '29' }), + /delegation\.child_timeout_seconds/, + ) +})