diff --git a/scripts/dev-api.js b/scripts/dev-api.js index a144a5d..e2815c3 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3403,6 +3403,56 @@ export function mergeHermesCompressionConfig(config = {}, form = {}) { return next } +export function buildHermesToolLoopGuardrailsConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const guardrails = root.tool_loop_guardrails && typeof root.tool_loop_guardrails === 'object' && !Array.isArray(root.tool_loop_guardrails) + ? root.tool_loop_guardrails + : {} + const warnAfter = guardrails.warn_after && typeof guardrails.warn_after === 'object' && !Array.isArray(guardrails.warn_after) + ? guardrails.warn_after + : {} + const hardStopAfter = guardrails.hard_stop_after && typeof guardrails.hard_stop_after === 'object' && !Array.isArray(guardrails.hard_stop_after) + ? guardrails.hard_stop_after + : {} + return { + warningsEnabled: readHermesBool(guardrails.warnings_enabled, true), + hardStopEnabled: readHermesBool(guardrails.hard_stop_enabled, false), + warnExactFailure: parseHermesInteger(warnAfter.exact_failure ?? guardrails.exact_failure_warn_after, 'tool_loop_guardrails.warn_after.exact_failure', 2, 1, 100, false), + warnSameToolFailure: parseHermesInteger(warnAfter.same_tool_failure ?? guardrails.same_tool_failure_warn_after, 'tool_loop_guardrails.warn_after.same_tool_failure', 3, 1, 100, false), + warnNoProgress: parseHermesInteger(warnAfter.idempotent_no_progress ?? guardrails.no_progress_warn_after, 'tool_loop_guardrails.warn_after.idempotent_no_progress', 2, 1, 100, false), + hardStopExactFailure: parseHermesInteger(hardStopAfter.exact_failure ?? guardrails.exact_failure_block_after, 'tool_loop_guardrails.hard_stop_after.exact_failure', 5, 1, 100, false), + hardStopSameToolFailure: parseHermesInteger(hardStopAfter.same_tool_failure ?? guardrails.same_tool_failure_halt_after, 'tool_loop_guardrails.hard_stop_after.same_tool_failure', 8, 1, 100, false), + hardStopNoProgress: parseHermesInteger(hardStopAfter.idempotent_no_progress ?? guardrails.no_progress_block_after, 'tool_loop_guardrails.hard_stop_after.idempotent_no_progress', 5, 1, 100, false), + } +} + +export function mergeHermesToolLoopGuardrailsConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesToolLoopGuardrailsConfigValues(next) + const guardrails = next.tool_loop_guardrails && typeof next.tool_loop_guardrails === 'object' && !Array.isArray(next.tool_loop_guardrails) + ? mergeConfigsPreservingFields(next.tool_loop_guardrails, {}) + : {} + const warnAfter = guardrails.warn_after && typeof guardrails.warn_after === 'object' && !Array.isArray(guardrails.warn_after) + ? mergeConfigsPreservingFields(guardrails.warn_after, {}) + : {} + const hardStopAfter = guardrails.hard_stop_after && typeof guardrails.hard_stop_after === 'object' && !Array.isArray(guardrails.hard_stop_after) + ? mergeConfigsPreservingFields(guardrails.hard_stop_after, {}) + : {} + + guardrails.warnings_enabled = formHermesBool(form, 'warningsEnabled', currentValues.warningsEnabled) + guardrails.hard_stop_enabled = formHermesBool(form, 'hardStopEnabled', currentValues.hardStopEnabled) + warnAfter.exact_failure = parseHermesInteger(Object.hasOwn(form, 'warnExactFailure') ? form.warnExactFailure : currentValues.warnExactFailure, 'tool_loop_guardrails.warn_after.exact_failure', 2, 1, 100, true) + warnAfter.same_tool_failure = parseHermesInteger(Object.hasOwn(form, 'warnSameToolFailure') ? form.warnSameToolFailure : currentValues.warnSameToolFailure, 'tool_loop_guardrails.warn_after.same_tool_failure', 3, 1, 100, true) + warnAfter.idempotent_no_progress = parseHermesInteger(Object.hasOwn(form, 'warnNoProgress') ? form.warnNoProgress : currentValues.warnNoProgress, 'tool_loop_guardrails.warn_after.idempotent_no_progress', 2, 1, 100, true) + hardStopAfter.exact_failure = parseHermesInteger(Object.hasOwn(form, 'hardStopExactFailure') ? form.hardStopExactFailure : currentValues.hardStopExactFailure, 'tool_loop_guardrails.hard_stop_after.exact_failure', 5, 1, 100, true) + hardStopAfter.same_tool_failure = parseHermesInteger(Object.hasOwn(form, 'hardStopSameToolFailure') ? form.hardStopSameToolFailure : currentValues.hardStopSameToolFailure, 'tool_loop_guardrails.hard_stop_after.same_tool_failure', 8, 1, 100, true) + hardStopAfter.idempotent_no_progress = parseHermesInteger(Object.hasOwn(form, 'hardStopNoProgress') ? form.hardStopNoProgress : currentValues.hardStopNoProgress, 'tool_loop_guardrails.hard_stop_after.idempotent_no_progress', 5, 1, 100, true) + guardrails.warn_after = warnAfter + guardrails.hard_stop_after = hardStopAfter + next.tool_loop_guardrails = guardrails + 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) @@ -9646,6 +9696,27 @@ const handlers = { } }, + hermes_tool_loop_guardrails_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesToolLoopGuardrailsConfigValues(config), + } + }, + + hermes_tool_loop_guardrails_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesToolLoopGuardrailsConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesToolLoopGuardrailsConfigValues(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 81e798c..111f40c 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -3163,6 +3163,164 @@ fn merge_hermes_compression_config( Ok(()) } +fn build_hermes_tool_loop_guardrails_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let guardrails = root.and_then(|map| yaml_get_mapping(map, "tool_loop_guardrails")); + let warn_after = guardrails.and_then(|map| yaml_get_mapping(map, "warn_after")); + let hard_stop_after = guardrails.and_then(|map| yaml_get_mapping(map, "hard_stop_after")); + + let warnings_enabled = guardrails + .and_then(|map| yaml_bool_field(map, "warnings_enabled")) + .unwrap_or(true); + let hard_stop_enabled = guardrails + .and_then(|map| yaml_bool_field(map, "hard_stop_enabled")) + .unwrap_or(false); + let warn_exact_failure = warn_after + .and_then(|map| yaml_i64_field(map, "exact_failure")) + .or_else(|| guardrails.and_then(|map| yaml_i64_field(map, "exact_failure_warn_after"))); + let warn_same_tool_failure = warn_after + .and_then(|map| yaml_i64_field(map, "same_tool_failure")) + .or_else(|| guardrails.and_then(|map| yaml_i64_field(map, "same_tool_failure_warn_after"))); + let warn_no_progress = warn_after + .and_then(|map| yaml_i64_field(map, "idempotent_no_progress")) + .or_else(|| guardrails.and_then(|map| yaml_i64_field(map, "no_progress_warn_after"))); + let hard_stop_exact_failure = hard_stop_after + .and_then(|map| yaml_i64_field(map, "exact_failure")) + .or_else(|| guardrails.and_then(|map| yaml_i64_field(map, "exact_failure_block_after"))); + let hard_stop_same_tool_failure = hard_stop_after + .and_then(|map| yaml_i64_field(map, "same_tool_failure")) + .or_else(|| guardrails.and_then(|map| yaml_i64_field(map, "same_tool_failure_halt_after"))); + let hard_stop_no_progress = hard_stop_after + .and_then(|map| yaml_i64_field(map, "idempotent_no_progress")) + .or_else(|| guardrails.and_then(|map| yaml_i64_field(map, "no_progress_block_after"))); + + serde_json::json!({ + "warningsEnabled": warnings_enabled, + "hardStopEnabled": hard_stop_enabled, + "warnExactFailure": bounded_hermes_i64(warn_exact_failure, 2, 1, 100), + "warnSameToolFailure": bounded_hermes_i64(warn_same_tool_failure, 3, 1, 100), + "warnNoProgress": bounded_hermes_i64(warn_no_progress, 2, 1, 100), + "hardStopExactFailure": bounded_hermes_i64(hard_stop_exact_failure, 5, 1, 100), + "hardStopSameToolFailure": bounded_hermes_i64(hard_stop_same_tool_failure, 8, 1, 100), + "hardStopNoProgress": bounded_hermes_i64(hard_stop_no_progress, 5, 1, 100), + }) +} + +fn merge_hermes_tool_loop_guardrails_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_tool_loop_guardrails_config_values(config); + let warnings_enabled = form_bool(form, "warningsEnabled") + .unwrap_or_else(|| current["warningsEnabled"].as_bool().unwrap_or(true)); + let hard_stop_enabled = form_bool(form, "hardStopEnabled") + .unwrap_or_else(|| current["hardStopEnabled"].as_bool().unwrap_or(false)); + let warn_exact_failure = validate_hermes_i64( + if form.get("warnExactFailure").is_some() { + form_i64(form, "warnExactFailure") + } else { + Some(current["warnExactFailure"].as_i64().unwrap_or(2)) + }, + "tool_loop_guardrails.warn_after.exact_failure", + 2, + 1, + 100, + )?; + let warn_same_tool_failure = validate_hermes_i64( + if form.get("warnSameToolFailure").is_some() { + form_i64(form, "warnSameToolFailure") + } else { + Some(current["warnSameToolFailure"].as_i64().unwrap_or(3)) + }, + "tool_loop_guardrails.warn_after.same_tool_failure", + 3, + 1, + 100, + )?; + let warn_no_progress = validate_hermes_i64( + if form.get("warnNoProgress").is_some() { + form_i64(form, "warnNoProgress") + } else { + Some(current["warnNoProgress"].as_i64().unwrap_or(2)) + }, + "tool_loop_guardrails.warn_after.idempotent_no_progress", + 2, + 1, + 100, + )?; + let hard_stop_exact_failure = validate_hermes_i64( + if form.get("hardStopExactFailure").is_some() { + form_i64(form, "hardStopExactFailure") + } else { + Some(current["hardStopExactFailure"].as_i64().unwrap_or(5)) + }, + "tool_loop_guardrails.hard_stop_after.exact_failure", + 5, + 1, + 100, + )?; + let hard_stop_same_tool_failure = validate_hermes_i64( + if form.get("hardStopSameToolFailure").is_some() { + form_i64(form, "hardStopSameToolFailure") + } else { + Some(current["hardStopSameToolFailure"].as_i64().unwrap_or(8)) + }, + "tool_loop_guardrails.hard_stop_after.same_tool_failure", + 8, + 1, + 100, + )?; + let hard_stop_no_progress = validate_hermes_i64( + if form.get("hardStopNoProgress").is_some() { + form_i64(form, "hardStopNoProgress") + } else { + Some(current["hardStopNoProgress"].as_i64().unwrap_or(5)) + }, + "tool_loop_guardrails.hard_stop_after.idempotent_no_progress", + 5, + 1, + 100, + )?; + + let root = ensure_yaml_object(config)?; + let guardrails = yaml_child_object(root, "tool_loop_guardrails")?; + guardrails.insert( + yaml_key("warnings_enabled"), + serde_yaml::Value::Bool(warnings_enabled), + ); + guardrails.insert( + yaml_key("hard_stop_enabled"), + serde_yaml::Value::Bool(hard_stop_enabled), + ); + let warn_after = yaml_child_object(guardrails, "warn_after")?; + warn_after.insert( + yaml_key("exact_failure"), + serde_yaml::Value::Number(warn_exact_failure.into()), + ); + warn_after.insert( + yaml_key("same_tool_failure"), + serde_yaml::Value::Number(warn_same_tool_failure.into()), + ); + warn_after.insert( + yaml_key("idempotent_no_progress"), + serde_yaml::Value::Number(warn_no_progress.into()), + ); + let hard_stop_after = yaml_child_object(guardrails, "hard_stop_after")?; + hard_stop_after.insert( + yaml_key("exact_failure"), + serde_yaml::Value::Number(hard_stop_exact_failure.into()), + ); + hard_stop_after.insert( + yaml_key("same_tool_failure"), + serde_yaml::Value::Number(hard_stop_same_tool_failure.into()), + ); + hard_stop_after.insert( + yaml_key("idempotent_no_progress"), + serde_yaml::Value::Number(hard_stop_no_progress.into()), + ); + 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")); @@ -3997,6 +4155,30 @@ pub fn hermes_compression_config_save(form: Value) -> Result { })) } +#[tauri::command] +pub fn hermes_tool_loop_guardrails_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_tool_loop_guardrails_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_tool_loop_guardrails_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_tool_loop_guardrails_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_tool_loop_guardrails_config_values(&config), + })) +} + // --------------------------------------------------------------------------- // hermes_read_config — 读取 Hermes config.yaml + .env // --------------------------------------------------------------------------- @@ -9055,6 +9237,134 @@ streaming: } } +#[cfg(test)] +mod hermes_tool_loop_guardrails_config_tests { + use super::{ + build_hermes_tool_loop_guardrails_config_values, merge_hermes_tool_loop_guardrails_config, + }; + use serde_json::json; + + #[test] + fn tool_loop_guardrails_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_tool_loop_guardrails_config_values(&config); + assert_eq!(values["warningsEnabled"], true); + assert_eq!(values["hardStopEnabled"], false); + assert_eq!(values["warnExactFailure"], 2); + assert_eq!(values["warnSameToolFailure"], 3); + assert_eq!(values["warnNoProgress"], 2); + assert_eq!(values["hardStopExactFailure"], 5); + assert_eq!(values["hardStopSameToolFailure"], 8); + assert_eq!(values["hardStopNoProgress"], 5); + } + + #[test] + fn merge_tool_loop_guardrails_config_preserves_unrelated_yaml() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +tool_loop_guardrails: + warnings_enabled: true + custom_flag: keep-me + warn_after: + exact_failure: 2 + custom_warn: 99 +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_tool_loop_guardrails_config( + &mut config, + &json!({ + "warningsEnabled": false, + "hardStopEnabled": true, + "warnExactFailure": "3", + "warnSameToolFailure": "4", + "warnNoProgress": "5", + "hardStopExactFailure": "6", + "hardStopSameToolFailure": "7", + "hardStopNoProgress": "8", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!( + config["tool_loop_guardrails"]["warnings_enabled"].as_bool(), + Some(false) + ); + assert_eq!( + config["tool_loop_guardrails"]["hard_stop_enabled"].as_bool(), + Some(true) + ); + assert_eq!( + config["tool_loop_guardrails"]["custom_flag"].as_str(), + Some("keep-me") + ); + assert_eq!( + config["tool_loop_guardrails"]["warn_after"]["exact_failure"].as_i64(), + Some(3) + ); + assert_eq!( + config["tool_loop_guardrails"]["warn_after"]["same_tool_failure"].as_i64(), + Some(4) + ); + assert_eq!( + config["tool_loop_guardrails"]["warn_after"]["idempotent_no_progress"].as_i64(), + Some(5) + ); + assert_eq!( + config["tool_loop_guardrails"]["warn_after"]["custom_warn"].as_i64(), + Some(99) + ); + assert_eq!( + config["tool_loop_guardrails"]["hard_stop_after"]["exact_failure"].as_i64(), + Some(6) + ); + assert_eq!( + config["tool_loop_guardrails"]["hard_stop_after"]["same_tool_failure"].as_i64(), + Some(7) + ); + assert_eq!( + config["tool_loop_guardrails"]["hard_stop_after"]["idempotent_no_progress"].as_i64(), + Some(8) + ); + } + + #[test] + fn merge_tool_loop_guardrails_config_rejects_invalid_values() { + let mut config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let err = merge_hermes_tool_loop_guardrails_config( + &mut config, + &json!({ "warnExactFailure": 0 }), + ) + .unwrap_err(); + assert!(err.contains("tool_loop_guardrails.warn_after.exact_failure")); + let err = merge_hermes_tool_loop_guardrails_config( + &mut config, + &json!({ "warnSameToolFailure": 101 }), + ) + .unwrap_err(); + assert!(err.contains("tool_loop_guardrails.warn_after.same_tool_failure")); + let err = merge_hermes_tool_loop_guardrails_config( + &mut config, + &json!({ "hardStopExactFailure": 0 }), + ) + .unwrap_err(); + assert!(err.contains("tool_loop_guardrails.hard_stop_after.exact_failure")); + let err = merge_hermes_tool_loop_guardrails_config( + &mut config, + &json!({ "hardStopNoProgress": 101 }), + ) + .unwrap_err(); + assert!(err.contains("tool_loop_guardrails.hard_stop_after.idempotent_no_progress")); + } +} + #[cfg(test)] mod hermes_channel_tests { use super::{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d71e16..37bb081 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -261,6 +261,8 @@ pub fn run() { hermes::hermes_session_runtime_config_save, hermes::hermes_compression_config_read, hermes::hermes_compression_config_save, + hermes::hermes_tool_loop_guardrails_config_read, + hermes::hermes_tool_loop_guardrails_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 fded1de..7eba00f 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -23,6 +23,17 @@ const COMPRESSION_DEFAULTS = { abortOnSummaryFailure: false, } +const TOOL_GUARDRAILS_DEFAULTS = { + warningsEnabled: true, + hardStopEnabled: false, + warnExactFailure: 2, + warnSameToolFailure: 3, + warnNoProgress: 2, + hardStopExactFailure: 5, + hardStopSameToolFailure: 8, + hardStopNoProgress: 5, +} + const SESSION_RESET_MODES = ['both', 'idle', 'daily', 'none'] export function render() { @@ -32,15 +43,19 @@ export function render() { let yaml = '' let runtimeValues = { ...SESSION_RUNTIME_DEFAULTS } let compressionValues = { ...COMPRESSION_DEFAULTS } + let toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS } let loading = true let runtimeLoading = true let compressionLoading = true + let toolGuardrailsLoading = true let saving = false let runtimeSaving = false let compressionSaving = false + let toolGuardrailsSaving = false let error = null let runtimeError = null let compressionError = null + let toolGuardrailsError = null function esc(value) { return String(value || '') @@ -51,7 +66,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || saving || runtimeSaving || compressionSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving } function option(labelKey, value, selected) { @@ -68,7 +83,7 @@ export function render() { } function renderRuntimePanel() { - const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving + const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving return `
@@ -116,7 +131,7 @@ export function render() { } function renderCompressionPanel() { - const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving + const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving return `
@@ -165,6 +180,68 @@ export function render() { ` } + function renderToolGuardrailsPanel() { + const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving + return ` +
+
+
+
${t('engine.hermesToolGuardrailsTitle')}
+
${t('engine.hermesToolGuardrailsDesc')}
+
+
+ ${toolGuardrailsSaving ? t('engine.hermesConfigStatusSaving') : toolGuardrailsLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesToolGuardrailsStatusReady')} + +
+
+
+ ${renderError(toolGuardrailsError)} +
+ + +
+
${t('engine.hermesToolGuardrailsWarnAfterTitle')}
+
+ + + +
+
${t('engine.hermesToolGuardrailsHardStopAfterTitle')}
+
+ + + +
+
${t('engine.hermesToolGuardrailsFootnote')}
+
+
+ ` + } + function draw() { el.innerHTML = `
@@ -181,6 +258,7 @@ export function render() { ${renderRuntimePanel()} ${renderCompressionPanel()} + ${renderToolGuardrailsPanel()}
@@ -202,6 +280,7 @@ export function render() { el.querySelector('#hm-config-save')?.addEventListener('click', save) el.querySelector('#hm-runtime-save')?.addEventListener('click', saveRuntime) el.querySelector('#hm-compression-save')?.addEventListener('click', saveCompression) + el.querySelector('#hm-tool-guardrails-save')?.addEventListener('click', saveToolGuardrails) } async function loadRaw() { @@ -219,13 +298,20 @@ export function render() { compressionValues = { ...COMPRESSION_DEFAULTS, ...(data?.values || {}) } } + async function loadToolGuardrails() { + const data = await api.hermesToolLoopGuardrailsConfigRead() + toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS, ...(data?.values || {}) } + } + async function load() { loading = true runtimeLoading = true compressionLoading = true + toolGuardrailsLoading = true error = null runtimeError = null compressionError = null + toolGuardrailsError = null draw() try { await loadRaw() @@ -250,6 +336,14 @@ export function render() { compressionLoading = false draw() } + try { + await loadToolGuardrails() + } catch (err) { + toolGuardrailsError = humanizeError(err, t('engine.hermesToolGuardrailsLoadFailed') || 'Load tool guardrail config failed') + } finally { + toolGuardrailsLoading = false + draw() + } } async function refreshRawAfterStructuredSave() { @@ -277,6 +371,9 @@ export function render() { try { await loadCompression() } catch {} + try { + await loadToolGuardrails() + } catch {} } catch (err) { error = humanizeError(err, t('engine.hermesConfigSaveFailed') || 'Save failed') toast(error, 'error') @@ -345,6 +442,38 @@ export function render() { } } + async function saveToolGuardrails() { + const form = { + warningsEnabled: !!el.querySelector('#hm-tool-guardrails-warnings-enabled')?.checked, + hardStopEnabled: !!el.querySelector('#hm-tool-guardrails-hard-stop-enabled')?.checked, + warnExactFailure: el.querySelector('#hm-tool-guardrails-warn-exact-failure')?.value || '2', + warnSameToolFailure: el.querySelector('#hm-tool-guardrails-warn-same-tool-failure')?.value || '3', + warnNoProgress: el.querySelector('#hm-tool-guardrails-warn-no-progress')?.value || '2', + hardStopExactFailure: el.querySelector('#hm-tool-guardrails-hard-stop-exact-failure')?.value || '5', + hardStopSameToolFailure: el.querySelector('#hm-tool-guardrails-hard-stop-same-tool-failure')?.value || '8', + hardStopNoProgress: el.querySelector('#hm-tool-guardrails-hard-stop-no-progress')?.value || '5', + } + toolGuardrailsSaving = true + toolGuardrailsError = null + draw() + try { + const result = await api.hermesToolLoopGuardrailsConfigSave(form) + toolGuardrailsValues = { ...TOOL_GUARDRAILS_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesToolGuardrailsSaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + toolGuardrailsError = humanizeError(err, t('engine.hermesToolGuardrailsSaveFailed') || 'Save tool guardrail config failed') + toast(toolGuardrailsError, 'error') + } finally { + toolGuardrailsSaving = false + draw() + } + } + draw() load() return el diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 5f2ad5b..eae42e2 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -6757,6 +6757,17 @@ body[data-active-engine="hermes"][data-theme="dark"] { grid-template-columns: repeat(4, minmax(140px, 1fr)); margin-top: 18px; } +[data-engine="hermes"] .hm-config-guardrails-grid { + grid-template-columns: repeat(3, minmax(160px, 1fr)); + margin-top: 12px; +} +[data-engine="hermes"] .hm-config-subtitle { + margin-top: 20px; + color: var(--hm-text-secondary); + font-family: var(--hm-font-serif); + font-size: 13px; + font-weight: 500; +} [data-engine="hermes"] .hm-config-check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -6881,6 +6892,7 @@ body[data-active-engine="hermes"][data-theme="dark"] { } [data-engine="hermes"] .hm-config-runtime-grid, [data-engine="hermes"] .hm-config-compression-grid, + [data-engine="hermes"] .hm-config-guardrails-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 0b90089..7e34871 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -513,6 +513,8 @@ export const api = { hermesSessionRuntimeConfigSave: (form) => invoke('hermes_session_runtime_config_save', { form }), hermesCompressionConfigRead: () => invoke('hermes_compression_config_read'), hermesCompressionConfigSave: (form) => invoke('hermes_compression_config_save', { form }), + hermesToolLoopGuardrailsConfigRead: () => invoke('hermes_tool_loop_guardrails_config_read'), + hermesToolLoopGuardrailsConfigSave: (form) => invoke('hermes_tool_loop_guardrails_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 0e6ef1c..4cfc1c5 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -512,6 +512,24 @@ export default { hermesCompressionProtectFirstN: _('保护开头消息数', 'Protect first messages', '保護開頭訊息數'), hermesCompressionAbortOnSummaryFailure: _('摘要失败时中止回复', 'Abort when summarization fails', '摘要失敗時中止回覆'), hermesCompressionFootnote: _('阈值和目标比例越低,压缩越早、越激进。建议先使用默认值,再根据真实 Gateway 日志调整。', 'Lower thresholds and target ratios compress earlier and more aggressively. Start with the defaults, then tune with real Gateway logs.', '閾值和目標比例越低,壓縮越早、越激進。建議先使用預設值,再根據真實 Gateway 日誌調整。'), + hermesToolGuardrailsTitle: _('工具循环防护', 'Tool loop guardrails', '工具循環防護'), + hermesToolGuardrailsDesc: _('当 Agent 重复失败或反复执行无进展工具时,先给模型修正提示;开启硬停止后可主动中止失控循环。', 'Warn the model when tools repeat failures or make no progress. Enable hard stops to halt runaway loops before they spend the full turn budget.', '當 Agent 重複失敗或反覆執行無進展工具時,先給模型修正提示;啟用硬停止後可主動中止失控循環。'), + hermesToolGuardrailsStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesToolGuardrailsSave: _('保存防护配置', 'Save guardrail settings', '儲存防護設定'), + hermesToolGuardrailsSaveSuccess: _('工具循环防护已保存,建议重启 Hermes Gateway 生效', 'Tool loop guardrails saved. Restart Hermes Gateway to take effect.', '工具循環防護已儲存,建議重啟 Hermes Gateway 生效'), + hermesToolGuardrailsLoadFailed: _('加载工具循环防护失败', 'Load tool loop guardrails failed', '載入工具循環防護失敗'), + hermesToolGuardrailsSaveFailed: _('保存工具循环防护失败', 'Save tool loop guardrails failed', '儲存工具循環防護失敗'), + hermesToolGuardrailsWarningsEnabled: _('启用软警告', 'Enable soft warnings', '啟用軟警告'), + hermesToolGuardrailsHardStopEnabled: _('启用硬停止', 'Enable hard stops', '啟用硬停止'), + hermesToolGuardrailsWarnAfterTitle: _('软警告阈值', 'Soft warning thresholds', '軟警告閾值'), + hermesToolGuardrailsHardStopAfterTitle: _('硬停止阈值', 'Hard stop thresholds', '硬停止閾值'), + hermesToolGuardrailsWarnExactFailure: _('相同失败警告', 'Exact failure warning', '相同失敗警告'), + hermesToolGuardrailsWarnSameToolFailure: _('同工具失败警告', 'Same-tool failure warning', '同工具失敗警告'), + hermesToolGuardrailsWarnNoProgress: _('无进展警告', 'No-progress warning', '無進展警告'), + hermesToolGuardrailsHardStopExactFailure: _('相同失败停止', 'Exact failure stop', '相同失敗停止'), + hermesToolGuardrailsHardStopSameToolFailure: _('同工具失败停止', 'Same-tool failure stop', '同工具失敗停止'), + hermesToolGuardrailsHardStopNoProgress: _('无进展停止', 'No-progress stop', '無進展停止'), + hermesToolGuardrailsFootnote: _('默认只提示不拦截,适合交互式使用。硬停止更适合 cron、无人值守和长时间后台任务。', 'By default Hermes only warns and does not block, which fits interactive use. Hard stops are better for cron, unattended, and long-running background jobs.', '預設只提示不攔截,適合互動式使用。硬停止更適合 cron、無人值守和長時間背景任務。'), // Batch 1 §E: 会话导出 sessionsExport: _('导出', 'Export', '匯出'), sessionsExportSuccess: _('已导出', 'Exported', '已匯出'), diff --git a/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js new file mode 100644 index 0000000..7ed8b5a --- /dev/null +++ b/tests/hermes-config-page-ui.test.js @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { t } from '../src/lib/i18n.js' + +const source = readFileSync(new URL('../src/engines/hermes/pages/config.js', import.meta.url), 'utf8') + +function extractEngineKeys() { + return [...source.matchAll(/['"](engine\.[A-Za-z0-9_.-]+)['"]/g)].map(match => match[1]) +} + +test('Hermes 配置页会暴露工具循环防护结构化配置字段', () => { + for (const id of [ + 'hm-tool-guardrails-save', + 'hm-tool-guardrails-warnings-enabled', + 'hm-tool-guardrails-hard-stop-enabled', + 'hm-tool-guardrails-warn-exact-failure', + 'hm-tool-guardrails-warn-same-tool-failure', + 'hm-tool-guardrails-warn-no-progress', + 'hm-tool-guardrails-hard-stop-exact-failure', + 'hm-tool-guardrails-hard-stop-same-tool-failure', + 'hm-tool-guardrails-hard-stop-no-progress', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + +test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => { + const keys = new Set(extractEngineKeys().filter(key => key.includes('ToolGuardrails'))) + + assert.ok(keys.size > 0, '应能提取工具循环防护用到的 engine 翻译 key') + for (const key of keys) { + assert.notEqual(t(key), key, `${key} 缺少运行时翻译`) + } +}) diff --git a/tests/hermes-tool-loop-guardrails-config.test.js b/tests/hermes-tool-loop-guardrails-config.test.js new file mode 100644 index 0000000..a880604 --- /dev/null +++ b/tests/hermes-tool-loop-guardrails-config.test.js @@ -0,0 +1,106 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesToolLoopGuardrailsConfigValues, + mergeHermesToolLoopGuardrailsConfig, +} from '../scripts/dev-api.js' + +test('Hermes 工具循环防护读取会提供上游默认值', () => { + const values = buildHermesToolLoopGuardrailsConfigValues({}) + + assert.deepEqual(values, { + warningsEnabled: true, + hardStopEnabled: false, + warnExactFailure: 2, + warnSameToolFailure: 3, + warnNoProgress: 2, + hardStopExactFailure: 5, + hardStopSameToolFailure: 8, + hardStopNoProgress: 5, + }) +}) + +test('Hermes 工具循环防护读取会回显嵌套阈值字段', () => { + const values = buildHermesToolLoopGuardrailsConfigValues({ + tool_loop_guardrails: { + warnings_enabled: false, + hard_stop_enabled: true, + warn_after: { + exact_failure: 3, + same_tool_failure: 4, + idempotent_no_progress: 5, + }, + hard_stop_after: { + exact_failure: 6, + same_tool_failure: 7, + idempotent_no_progress: 8, + }, + }, + }) + + assert.equal(values.warningsEnabled, false) + assert.equal(values.hardStopEnabled, true) + assert.equal(values.warnExactFailure, 3) + assert.equal(values.warnSameToolFailure, 4) + assert.equal(values.warnNoProgress, 5) + assert.equal(values.hardStopExactFailure, 6) + assert.equal(values.hardStopSameToolFailure, 7) + assert.equal(values.hardStopNoProgress, 8) +}) + +test('Hermes 工具循环防护保存会保留无关 YAML 并写入上游嵌套结构', () => { + const next = mergeHermesToolLoopGuardrailsConfig({ + model: { provider: 'anthropic' }, + tool_loop_guardrails: { + warnings_enabled: true, + custom_flag: 'keep-me', + warn_after: { + exact_failure: 2, + custom_warn: 99, + }, + }, + streaming: { enabled: true }, + }, { + warningsEnabled: false, + hardStopEnabled: true, + warnExactFailure: '3', + warnSameToolFailure: '4', + warnNoProgress: '5', + hardStopExactFailure: '6', + hardStopSameToolFailure: '7', + hardStopNoProgress: '8', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.tool_loop_guardrails.warnings_enabled, false) + assert.equal(next.tool_loop_guardrails.hard_stop_enabled, true) + assert.equal(next.tool_loop_guardrails.custom_flag, 'keep-me') + assert.equal(next.tool_loop_guardrails.warn_after.exact_failure, 3) + assert.equal(next.tool_loop_guardrails.warn_after.same_tool_failure, 4) + assert.equal(next.tool_loop_guardrails.warn_after.idempotent_no_progress, 5) + assert.equal(next.tool_loop_guardrails.warn_after.custom_warn, 99) + assert.equal(next.tool_loop_guardrails.hard_stop_after.exact_failure, 6) + assert.equal(next.tool_loop_guardrails.hard_stop_after.same_tool_failure, 7) + assert.equal(next.tool_loop_guardrails.hard_stop_after.idempotent_no_progress, 8) +}) + +test('Hermes 工具循环防护保存会拒绝越界阈值', () => { + assert.throws( + () => mergeHermesToolLoopGuardrailsConfig({}, { warnExactFailure: '0' }), + /tool_loop_guardrails\.warn_after\.exact_failure/, + ) + assert.throws( + () => mergeHermesToolLoopGuardrailsConfig({}, { warnSameToolFailure: '101' }), + /tool_loop_guardrails\.warn_after\.same_tool_failure/, + ) + assert.throws( + () => mergeHermesToolLoopGuardrailsConfig({}, { hardStopExactFailure: '0' }), + /tool_loop_guardrails\.hard_stop_after\.exact_failure/, + ) + assert.throws( + () => mergeHermesToolLoopGuardrailsConfig({}, { hardStopNoProgress: '101' }), + /tool_loop_guardrails\.hard_stop_after\.idempotent_no_progress/, + ) +})