diff --git a/scripts/dev-api.js b/scripts/dev-api.js index b621960..31e924e 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3894,6 +3894,34 @@ export function buildHermesExecutionLimitsConfigValues(config = {}) { } } +export function buildHermesIoSafetyConfigValues(config = {}) { + const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} + const toolOutput = root.tool_output && typeof root.tool_output === 'object' && !Array.isArray(root.tool_output) + ? root.tool_output + : {} + return { + fileReadMaxChars: parseHermesInteger(root.file_read_max_chars, 'file_read_max_chars', 100000, 1000, 1000000, false), + toolOutputMaxBytes: parseHermesInteger(toolOutput.max_bytes, 'tool_output.max_bytes', 50000, 1000, 1000000, false), + toolOutputMaxLines: parseHermesInteger(toolOutput.max_lines, 'tool_output.max_lines', 2000, 1, 100000, false), + toolOutputMaxLineLength: parseHermesInteger(toolOutput.max_line_length, 'tool_output.max_line_length', 2000, 1, 100000, false), + } +} + +export function mergeHermesIoSafetyConfig(config = {}, form = {}) { + const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {}) + const currentValues = buildHermesIoSafetyConfigValues(next) + const toolOutput = next.tool_output && typeof next.tool_output === 'object' && !Array.isArray(next.tool_output) + ? mergeConfigsPreservingFields(next.tool_output, {}) + : {} + + next.file_read_max_chars = parseHermesInteger(Object.hasOwn(form, 'fileReadMaxChars') ? form.fileReadMaxChars : currentValues.fileReadMaxChars, 'file_read_max_chars', 100000, 1000, 1000000, true) + toolOutput.max_bytes = parseHermesInteger(Object.hasOwn(form, 'toolOutputMaxBytes') ? form.toolOutputMaxBytes : currentValues.toolOutputMaxBytes, 'tool_output.max_bytes', 50000, 1000, 1000000, true) + toolOutput.max_lines = parseHermesInteger(Object.hasOwn(form, 'toolOutputMaxLines') ? form.toolOutputMaxLines : currentValues.toolOutputMaxLines, 'tool_output.max_lines', 2000, 1, 100000, true) + toolOutput.max_line_length = parseHermesInteger(Object.hasOwn(form, 'toolOutputMaxLineLength') ? form.toolOutputMaxLineLength : currentValues.toolOutputMaxLineLength, 'tool_output.max_line_length', 2000, 1, 100000, true) + next.tool_output = toolOutput + return next +} + export function buildHermesTerminalConfigValues(config = {}) { const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {} const terminal = root.terminal && typeof root.terminal === 'object' && !Array.isArray(root.terminal) @@ -10475,6 +10503,27 @@ const handlers = { } }, + hermes_io_safety_config_read() { + const { configPath, exists, config } = readHermesConfigYamlObject() + return { + exists, + configPath, + values: buildHermesIoSafetyConfigValues(config), + } + }, + + hermes_io_safety_config_save({ form } = {}) { + const { configPath, config } = readHermesConfigYamlObject() + const next = mergeHermesIoSafetyConfig(config, form || {}) + const backup = writeHermesConfigYamlObject(configPath, next) + return { + ok: true, + configPath, + backup, + values: buildHermesIoSafetyConfigValues(next), + } + }, + hermes_terminal_config_read() { const { configPath, exists, config } = readHermesConfigYamlObject() return { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 599ebe9..64a4ca4 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -4565,6 +4565,108 @@ fn build_hermes_execution_limits_config_values(config: &serde_yaml::Value) -> Va }) } +fn build_hermes_io_safety_config_values(config: &serde_yaml::Value) -> Value { + let root = config.as_mapping(); + let tool_output = root.and_then(|map| yaml_get_mapping(map, "tool_output")); + let file_read_max_chars = root + .map(|map| { + bounded_hermes_i64( + yaml_i64_field(map, "file_read_max_chars"), + 100000, + 1000, + 1000000, + ) + }) + .unwrap_or(100000); + let tool_output_max_bytes = tool_output + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_bytes"), 50000, 1000, 1000000)) + .unwrap_or(50000); + let tool_output_max_lines = tool_output + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_lines"), 2000, 1, 100000)) + .unwrap_or(2000); + let tool_output_max_line_length = tool_output + .map(|map| bounded_hermes_i64(yaml_i64_field(map, "max_line_length"), 2000, 1, 100000)) + .unwrap_or(2000); + + serde_json::json!({ + "fileReadMaxChars": file_read_max_chars, + "toolOutputMaxBytes": tool_output_max_bytes, + "toolOutputMaxLines": tool_output_max_lines, + "toolOutputMaxLineLength": tool_output_max_line_length, + }) +} + +fn merge_hermes_io_safety_config( + config: &mut serde_yaml::Value, + form: &Value, +) -> Result<(), String> { + let current = build_hermes_io_safety_config_values(config); + let file_read_max_chars = validate_hermes_i64( + if form.get("fileReadMaxChars").is_some() { + form_i64(form, "fileReadMaxChars") + } else { + Some(current["fileReadMaxChars"].as_i64().unwrap_or(100000)) + }, + "file_read_max_chars", + 100000, + 1000, + 1000000, + )?; + let tool_output_max_bytes = validate_hermes_i64( + if form.get("toolOutputMaxBytes").is_some() { + form_i64(form, "toolOutputMaxBytes") + } else { + Some(current["toolOutputMaxBytes"].as_i64().unwrap_or(50000)) + }, + "tool_output.max_bytes", + 50000, + 1000, + 1000000, + )?; + let tool_output_max_lines = validate_hermes_i64( + if form.get("toolOutputMaxLines").is_some() { + form_i64(form, "toolOutputMaxLines") + } else { + Some(current["toolOutputMaxLines"].as_i64().unwrap_or(2000)) + }, + "tool_output.max_lines", + 2000, + 1, + 100000, + )?; + let tool_output_max_line_length = validate_hermes_i64( + if form.get("toolOutputMaxLineLength").is_some() { + form_i64(form, "toolOutputMaxLineLength") + } else { + Some(current["toolOutputMaxLineLength"].as_i64().unwrap_or(2000)) + }, + "tool_output.max_line_length", + 2000, + 1, + 100000, + )?; + + let root = ensure_yaml_object(config)?; + root.insert( + yaml_key("file_read_max_chars"), + serde_yaml::Value::Number(file_read_max_chars.into()), + ); + let tool_output = yaml_child_object(root, "tool_output")?; + tool_output.insert( + yaml_key("max_bytes"), + serde_yaml::Value::Number(tool_output_max_bytes.into()), + ); + tool_output.insert( + yaml_key("max_lines"), + serde_yaml::Value::Number(tool_output_max_lines.into()), + ); + tool_output.insert( + yaml_key("max_line_length"), + serde_yaml::Value::Number(tool_output_max_line_length.into()), + ); + Ok(()) +} + fn merge_hermes_execution_limits_config( config: &mut serde_yaml::Value, form: &Value, @@ -5988,6 +6090,30 @@ pub fn hermes_execution_limits_config_save(form: Value) -> Result })) } +#[tauri::command] +pub fn hermes_io_safety_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_io_safety_config_values(&config), + })) +} + +#[tauri::command] +pub fn hermes_io_safety_config_save(form: Value) -> Result { + let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?; + merge_hermes_io_safety_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_io_safety_config_values(&config), + })) +} + #[tauri::command] pub fn hermes_terminal_config_read() -> Result { let (config_path, exists, config) = read_hermes_channel_yaml_config()?; @@ -11504,6 +11630,101 @@ streaming: } } +#[cfg(test)] +mod hermes_io_safety_config_tests { + use super::{build_hermes_io_safety_config_values, merge_hermes_io_safety_config}; + use serde_json::json; + + #[test] + fn io_safety_values_have_upstream_defaults() { + let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let values = build_hermes_io_safety_config_values(&config); + assert_eq!(values["fileReadMaxChars"], 100000); + assert_eq!(values["toolOutputMaxBytes"], 50000); + assert_eq!(values["toolOutputMaxLines"], 2000); + assert_eq!(values["toolOutputMaxLineLength"], 2000); + } + + #[test] + fn io_safety_values_read_yaml_fields() { + let config: serde_yaml::Value = serde_yaml::from_str( + r#" +file_read_max_chars: 200000 +tool_output: + max_bytes: 150000 + max_lines: 5000 + max_line_length: 4000 +"#, + ) + .unwrap(); + let values = build_hermes_io_safety_config_values(&config); + assert_eq!(values["fileReadMaxChars"], 200000); + assert_eq!(values["toolOutputMaxBytes"], 150000); + assert_eq!(values["toolOutputMaxLines"], 5000); + assert_eq!(values["toolOutputMaxLineLength"], 4000); + } + + #[test] + fn merge_io_safety_config_preserves_unknown_fields() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +model: + provider: anthropic +file_read_max_chars: 100000 +tool_output: + max_bytes: 50000 + custom_flag: keep-output +streaming: + enabled: true +"#, + ) + .unwrap(); + + merge_hermes_io_safety_config( + &mut config, + &json!({ + "fileReadMaxChars": "120000", + "toolOutputMaxBytes": "80000", + "toolOutputMaxLines": "3000", + "toolOutputMaxLineLength": "2500", + }), + ) + .unwrap(); + + assert_eq!(config["model"]["provider"].as_str(), Some("anthropic")); + assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true)); + assert_eq!(config["file_read_max_chars"].as_i64(), Some(120000)); + assert_eq!(config["tool_output"]["max_bytes"].as_i64(), Some(80000)); + assert_eq!(config["tool_output"]["max_lines"].as_i64(), Some(3000)); + assert_eq!( + config["tool_output"]["max_line_length"].as_i64(), + Some(2500) + ); + assert_eq!( + config["tool_output"]["custom_flag"].as_str(), + Some("keep-output") + ); + } + + #[test] + fn merge_io_safety_config_rejects_invalid_values() { + let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + let err = merge_hermes_io_safety_config(&mut config, &json!({ "fileReadMaxChars": 999 })) + .unwrap_err(); + assert!(err.contains("file_read_max_chars")); + let err = merge_hermes_io_safety_config(&mut config, &json!({ "toolOutputMaxBytes": 999 })) + .unwrap_err(); + assert!(err.contains("tool_output.max_bytes")); + let err = merge_hermes_io_safety_config(&mut config, &json!({ "toolOutputMaxLines": 0 })) + .unwrap_err(); + assert!(err.contains("tool_output.max_lines")); + let err = + merge_hermes_io_safety_config(&mut config, &json!({ "toolOutputMaxLineLength": 0 })) + .unwrap_err(); + assert!(err.contains("tool_output.max_line_length")); + } +} + #[cfg(test)] mod hermes_terminal_config_tests { use super::{build_hermes_terminal_config_values, merge_hermes_terminal_config}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2d9931..420ddf9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -281,6 +281,8 @@ pub fn run() { hermes::hermes_streaming_config_save, hermes::hermes_execution_limits_config_read, hermes::hermes_execution_limits_config_save, + hermes::hermes_io_safety_config_read, + hermes::hermes_io_safety_config_save, hermes::hermes_terminal_config_read, hermes::hermes_terminal_config_save, hermes::hermes_lazy_deps_features, diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 3b99bfc..0fcd527 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -102,6 +102,13 @@ const EXECUTION_LIMITS_DEFAULTS = { delegationInheritMcpToolsets: true, } +const IO_SAFETY_DEFAULTS = { + fileReadMaxChars: 100000, + toolOutputMaxBytes: 50000, + toolOutputMaxLines: 2000, + toolOutputMaxLineLength: 2000, +} + const TERMINAL_DEFAULTS = { terminalBackend: 'local', terminalCwd: '.', @@ -142,6 +149,7 @@ export function render() { let humanDelayValues = { ...HUMAN_DELAY_DEFAULTS } let streamingValues = { ...STREAMING_DEFAULTS } let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS } + let ioSafetyValues = { ...IO_SAFETY_DEFAULTS } let terminalValues = { ...TERMINAL_DEFAULTS } let loading = true let runtimeLoading = true @@ -156,6 +164,7 @@ export function render() { let humanDelayLoading = true let streamingLoading = true let executionLimitsLoading = true + let ioSafetyLoading = true let terminalLoading = true let saving = false let runtimeSaving = false @@ -170,6 +179,7 @@ export function render() { let humanDelaySaving = false let streamingSaving = false let executionLimitsSaving = false + let ioSafetySaving = false let terminalSaving = false let error = null let runtimeError = null @@ -184,6 +194,7 @@ export function render() { let humanDelayError = null let streamingError = null let executionLimitsError = null + let ioSafetyError = null let terminalError = null function esc(value) { @@ -195,7 +206,7 @@ export function render() { } function isBusy() { - return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || terminalSaving + return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || skillsLoading || quickCommandsLoading || unauthorizedDmLoading || securityLoading || displayLoading || humanDelayLoading || streamingLoading || executionLimitsLoading || ioSafetyLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || securitySaving || displaySaving || humanDelaySaving || streamingSaving || executionLimitsSaving || ioSafetySaving || terminalSaving } function option(labelKey, value, selected) { @@ -777,6 +788,46 @@ export function render() { ` } + function renderIoSafetyPanel() { + const disabled = loading || saving || ioSafetyLoading || ioSafetySaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving + return ` +
+
+
+
${t('engine.hermesIoSafetyTitle')}
+
${t('engine.hermesIoSafetyDesc')}
+
+
+ ${ioSafetySaving ? t('engine.hermesConfigStatusSaving') : ioSafetyLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesIoSafetyStatusReady')} + +
+
+
+ ${renderError(ioSafetyError)} +
+ + + + +
+
${t('engine.hermesIoSafetyFootnote')}
+
+
+ ` + } + function renderTerminalPanel() { const disabled = loading || saving || terminalLoading || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || skillsSaving || quickCommandsSaving || unauthorizedDmSaving || streamingSaving || executionLimitsSaving return ` @@ -866,6 +917,7 @@ export function render() { ${renderTerminalPanel()} ${renderStreamingPanel()} ${renderExecutionLimitsPanel()} + ${renderIoSafetyPanel()} ${renderCompressionPanel()} ${renderToolGuardrailsPanel()} ${renderMemoryPanel()} @@ -906,6 +958,7 @@ export function render() { el.querySelector('#hm-human-delay-save')?.addEventListener('click', saveHumanDelayConfig) el.querySelector('#hm-streaming-save')?.addEventListener('click', saveStreaming) el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits) + el.querySelector('#hm-io-safety-save')?.addEventListener('click', saveIoSafety) el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal) } @@ -974,6 +1027,11 @@ export function render() { executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS, ...(data?.values || {}) } } + async function loadIoSafety() { + const data = await api.hermesIoSafetyConfigRead() + ioSafetyValues = { ...IO_SAFETY_DEFAULTS, ...(data?.values || {}) } + } + async function loadTerminal() { const data = await api.hermesTerminalConfigRead() terminalValues = { ...TERMINAL_DEFAULTS, ...(data?.values || {}) } @@ -993,6 +1051,7 @@ export function render() { humanDelayLoading = true streamingLoading = true executionLimitsLoading = true + ioSafetyLoading = true terminalLoading = true error = null runtimeError = null @@ -1007,6 +1066,7 @@ export function render() { humanDelayError = null streamingError = null executionLimitsError = null + ioSafetyError = null terminalError = null draw() try { @@ -1056,6 +1116,14 @@ export function render() { executionLimitsLoading = false draw() } + try { + await loadIoSafety() + } catch (err) { + ioSafetyError = humanizeError(err, t('engine.hermesIoSafetyLoadFailed') || 'Load input/output safety config failed') + } finally { + ioSafetyLoading = false + draw() + } try { await loadTerminal() } catch (err) { @@ -1177,6 +1245,9 @@ export function render() { try { await loadExecutionLimits() } catch {} + try { + await loadIoSafety() + } catch {} try { await loadTerminal() } catch {} @@ -1537,6 +1608,34 @@ export function render() { } } + async function saveIoSafety() { + const form = { + fileReadMaxChars: el.querySelector('#hm-file-read-max-chars')?.value || '100000', + toolOutputMaxBytes: el.querySelector('#hm-tool-output-max-bytes')?.value || '50000', + toolOutputMaxLines: el.querySelector('#hm-tool-output-max-lines')?.value || '2000', + toolOutputMaxLineLength: el.querySelector('#hm-tool-output-max-line-length')?.value || '2000', + } + ioSafetySaving = true + ioSafetyError = null + draw() + try { + const result = await api.hermesIoSafetyConfigSave(form) + ioSafetyValues = { ...IO_SAFETY_DEFAULTS, ...(result?.values || form) } + await refreshRawAfterStructuredSave() + const backup = result?.backup || '' + toast({ + message: t('engine.hermesIoSafetySaveSuccess'), + hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '', + }, 'success') + } catch (err) { + ioSafetyError = humanizeError(err, t('engine.hermesIoSafetySaveFailed') || 'Save input/output safety config failed') + toast(ioSafetyError, 'error') + } finally { + ioSafetySaving = false + draw() + } + } + async function saveTerminal() { const form = { terminalBackend: el.querySelector('#hm-terminal-backend')?.value || 'local', diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 66787b4..794ae4f 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -533,6 +533,8 @@ export const api = { hermesStreamingConfigSave: (form) => invoke('hermes_streaming_config_save', { form }), hermesExecutionLimitsConfigRead: () => invoke('hermes_execution_limits_config_read'), hermesExecutionLimitsConfigSave: (form) => invoke('hermes_execution_limits_config_save', { form }), + hermesIoSafetyConfigRead: () => invoke('hermes_io_safety_config_read'), + hermesIoSafetyConfigSave: (form) => invoke('hermes_io_safety_config_save', { form }), hermesTerminalConfigRead: () => invoke('hermes_terminal_config_read'), hermesTerminalConfigSave: (form) => invoke('hermes_terminal_config_save', { form }), hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000), diff --git a/src/locales/modules/engine.js b/src/locales/modules/engine.js index 2ac4b43..7ea69ac 100644 --- a/src/locales/modules/engine.js +++ b/src/locales/modules/engine.js @@ -564,6 +564,18 @@ export default { 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.', '預設會拒絕子任務危險命令審批,適合互動式和長跑任務。只有在完全信任無人值守環境時才啟用自動批准。'), + hermesIoSafetyTitle: _('输入输出保护', 'Input and output safety', '輸入輸出保護'), + hermesIoSafetyDesc: _('限制单次文件读取和工具输出体量,避免大文件或长日志一次性挤爆上下文。', 'Limit single file reads and tool output size so large files or long logs do not flood the context.', '限制單次檔案讀取和工具輸出體量,避免大型檔案或長日誌一次性擠爆上下文。'), + hermesIoSafetyStatusReady: _('结构化配置', 'structured settings', '結構化設定'), + hermesIoSafetySave: _('保存保护配置', 'Save safety limits', '儲存保護設定'), + hermesIoSafetySaveSuccess: _('输入输出保护已保存,建议重启 Hermes Gateway 生效', 'Input/output safety limits saved. Restart Hermes Gateway to take effect.', '輸入輸出保護已儲存,建議重啟 Hermes Gateway 生效'), + hermesIoSafetyLoadFailed: _('加载输入输出保护失败', 'Load input/output safety failed', '載入輸入輸出保護失敗'), + hermesIoSafetySaveFailed: _('保存输入输出保护失败', 'Save input/output safety failed', '儲存輸入輸出保護失敗'), + hermesIoSafetyFileReadMaxChars: _('单次文件读取字符上限', 'File read character cap', '單次檔案讀取字元上限'), + hermesIoSafetyToolOutputMaxBytes: _('终端输出字符上限', 'Terminal output character cap', '終端輸出字元上限'), + hermesIoSafetyToolOutputMaxLines: _('文件分页最大行数', 'File page line cap', '檔案分頁最大行數'), + hermesIoSafetyToolOutputMaxLineLength: _('单行显示字符上限', 'Per-line character cap', '單行顯示字元上限'), + hermesIoSafetyFootnote: _('默认值适合大多数模型;小上下文模型可降低这些上限。其他 tool_output 高级字段会保留在 raw YAML 中。', 'Defaults fit most models. Lower these limits for small-context models. Other advanced tool_output fields are preserved in raw YAML.', '預設值適合多數模型;小上下文模型可降低這些上限。其他 tool_output 進階欄位會保留在 raw YAML 中。'), 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/tests/hermes-config-page-ui.test.js b/tests/hermes-config-page-ui.test.js index 46a7b5c..a5fb81f 100644 --- a/tests/hermes-config-page-ui.test.js +++ b/tests/hermes-config-page-ui.test.js @@ -138,6 +138,18 @@ test('Hermes 配置页会暴露执行与委派限制结构化配置字段', () = } }) +test('Hermes 配置页会暴露输入输出保护结构化配置字段', () => { + for (const id of [ + 'hm-io-safety-save', + 'hm-file-read-max-chars', + 'hm-tool-output-max-bytes', + 'hm-tool-output-max-lines', + 'hm-tool-output-max-line-length', + ]) { + assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`) + } +}) + test('Hermes 配置页会暴露终端执行结构化配置字段', () => { for (const id of [ 'hm-terminal-save', diff --git a/tests/hermes-io-safety-config.test.js b/tests/hermes-io-safety-config.test.js new file mode 100644 index 0000000..3caa44f --- /dev/null +++ b/tests/hermes-io-safety-config.test.js @@ -0,0 +1,78 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildHermesIoSafetyConfigValues, + mergeHermesIoSafetyConfig, +} from '../scripts/dev-api.js' + +test('Hermes 输入输出保护读取会提供上游默认值', () => { + const values = buildHermesIoSafetyConfigValues({}) + + assert.deepEqual(values, { + fileReadMaxChars: 100000, + toolOutputMaxBytes: 50000, + toolOutputMaxLines: 2000, + toolOutputMaxLineLength: 2000, + }) +}) + +test('Hermes 输入输出保护读取会回显 YAML 字段', () => { + const values = buildHermesIoSafetyConfigValues({ + file_read_max_chars: 200000, + tool_output: { + max_bytes: 150000, + max_lines: 5000, + max_line_length: 4000, + }, + }) + + assert.equal(values.fileReadMaxChars, 200000) + assert.equal(values.toolOutputMaxBytes, 150000) + assert.equal(values.toolOutputMaxLines, 5000) + assert.equal(values.toolOutputMaxLineLength, 4000) +}) + +test('Hermes 输入输出保护保存会保留未知字段并写入上游结构', () => { + const next = mergeHermesIoSafetyConfig({ + model: { provider: 'anthropic' }, + file_read_max_chars: 100000, + tool_output: { + max_bytes: 50000, + custom_flag: 'keep-output', + }, + streaming: { enabled: true }, + }, { + fileReadMaxChars: '120000', + toolOutputMaxBytes: '80000', + toolOutputMaxLines: '3000', + toolOutputMaxLineLength: '2500', + }) + + assert.deepEqual(next.model, { provider: 'anthropic' }) + assert.deepEqual(next.streaming, { enabled: true }) + assert.equal(next.file_read_max_chars, 120000) + assert.equal(next.tool_output.max_bytes, 80000) + assert.equal(next.tool_output.max_lines, 3000) + assert.equal(next.tool_output.max_line_length, 2500) + assert.equal(next.tool_output.custom_flag, 'keep-output') +}) + +test('Hermes 输入输出保护保存会拒绝越界值', () => { + assert.throws( + () => mergeHermesIoSafetyConfig({}, { fileReadMaxChars: '999' }), + /file_read_max_chars/, + ) + assert.throws( + () => mergeHermesIoSafetyConfig({}, { toolOutputMaxBytes: '999' }), + /tool_output\.max_bytes/, + ) + assert.throws( + () => mergeHermesIoSafetyConfig({}, { toolOutputMaxLines: '0' }), + /tool_output\.max_lines/, + ) + assert.throws( + () => mergeHermesIoSafetyConfig({}, { toolOutputMaxLineLength: '0' }), + /tool_output\.max_line_length/, + ) +})