feat(hermes): add delegation model override config

This commit is contained in:
晴天
2026-05-26 06:11:25 +08:00
parent 56519808d7
commit c19d6e80d9
6 changed files with 134 additions and 6 deletions

View File

@@ -4880,6 +4880,8 @@ export function buildHermesExecutionLimitsConfigValues(config = {}) {
delegationOrchestratorEnabled: readHermesBool(delegation.orchestrator_enabled, true),
delegationSubagentAutoApprove: readHermesBool(delegation.subagent_auto_approve, false),
delegationInheritMcpToolsets: readHermesBool(delegation.inherit_mcp_toolsets, true),
delegationModel: typeof delegation.model === 'string' ? delegation.model.trim() : '',
delegationProvider: typeof delegation.provider === 'string' ? delegation.provider.trim() : '',
}
}
@@ -5198,6 +5200,12 @@ export function mergeHermesExecutionLimitsConfig(config = {}, form = {}) {
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)
const delegationModel = normalizeHermesModelConfigString(Object.hasOwn(form, 'delegationModel') ? form.delegationModel : currentValues.delegationModel, 'delegation.model')
if (delegationModel) delegation.model = delegationModel
else delete delegation.model
const delegationProvider = normalizeHermesModelConfigString(Object.hasOwn(form, 'delegationProvider') ? form.delegationProvider : currentValues.delegationProvider, 'delegation.provider')
if (delegationProvider) delegation.provider = delegationProvider
else delete delegation.provider
next.code_execution = codeExecution
next.delegation = delegation
return next

View File

@@ -6878,6 +6878,16 @@ fn build_hermes_execution_limits_config_values(config: &serde_yaml::Value) -> Va
let delegation_inherit_mcp_toolsets = delegation
.and_then(|map| yaml_bool_field(map, "inherit_mcp_toolsets"))
.unwrap_or(true);
let delegation_model = delegation
.and_then(|map| yaml_string_field(map, "model"))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
let delegation_provider = delegation
.and_then(|map| yaml_string_field(map, "provider"))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_default();
serde_json::json!({
"codeExecutionMode": code_execution_mode,
@@ -6890,6 +6900,8 @@ fn build_hermes_execution_limits_config_values(config: &serde_yaml::Value) -> Va
"delegationOrchestratorEnabled": delegation_orchestrator_enabled,
"delegationSubagentAutoApprove": delegation_subagent_auto_approve,
"delegationInheritMcpToolsets": delegation_inherit_mcp_toolsets,
"delegationModel": delegation_model,
"delegationProvider": delegation_provider,
})
}
@@ -7733,6 +7745,20 @@ fn merge_hermes_execution_limits_config(
.as_bool()
.unwrap_or(true)
});
let delegation_model = form_string(form, "delegationModel")
.or_else(|| current["delegationModel"].as_str().map(ToString::to_string))
.unwrap_or_default()
.trim()
.to_string();
let delegation_provider = form_string(form, "delegationProvider")
.or_else(|| {
current["delegationProvider"]
.as_str()
.map(ToString::to_string)
})
.unwrap_or_default()
.trim()
.to_string();
let root = ensure_yaml_object(config)?;
let code_execution = yaml_child_object(root, "code_execution")?;
@@ -7778,6 +7804,22 @@ fn merge_hermes_execution_limits_config(
yaml_key("inherit_mcp_toolsets"),
serde_yaml::Value::Bool(delegation_inherit_mcp_toolsets),
);
if delegation_model.is_empty() {
delegation.remove(yaml_key("model"));
} else {
delegation.insert(
yaml_key("model"),
serde_yaml::Value::String(delegation_model),
);
}
if delegation_provider.is_empty() {
delegation.remove(yaml_key("provider"));
} else {
delegation.insert(
yaml_key("provider"),
serde_yaml::Value::String(delegation_provider),
);
}
Ok(())
}
@@ -15465,6 +15507,8 @@ mod hermes_execution_limits_config_tests {
assert_eq!(values["delegationOrchestratorEnabled"], true);
assert_eq!(values["delegationSubagentAutoApprove"], false);
assert_eq!(values["delegationInheritMcpToolsets"], true);
assert_eq!(values["delegationModel"], "");
assert_eq!(values["delegationProvider"], "");
}
#[test]
@@ -15483,6 +15527,8 @@ delegation:
orchestrator_enabled: false
subagent_auto_approve: true
inherit_mcp_toolsets: false
model: google/gemini-3-flash-preview
provider: openrouter
"#,
)
.unwrap();
@@ -15497,6 +15543,8 @@ delegation:
assert_eq!(values["delegationOrchestratorEnabled"], false);
assert_eq!(values["delegationSubagentAutoApprove"], true);
assert_eq!(values["delegationInheritMcpToolsets"], false);
assert_eq!(values["delegationModel"], "google/gemini-3-flash-preview");
assert_eq!(values["delegationProvider"], "openrouter");
}
#[test]
@@ -15531,6 +15579,8 @@ streaming:
"delegationOrchestratorEnabled": false,
"delegationSubagentAutoApprove": true,
"delegationInheritMcpToolsets": false,
"delegationModel": "anthropic/claude-haiku-4.6",
"delegationProvider": "anthropic",
}),
)
.unwrap();
@@ -15569,11 +15619,40 @@ streaming:
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")
config["delegation"]["model"].as_str(),
Some("anthropic/claude-haiku-4.6")
);
assert_eq!(config["delegation"]["provider"].as_str(), Some("anthropic"));
assert_eq!(
config["delegation"]["custom_flag"].as_str(),
Some("keep-delegation")
);
}
#[test]
fn merge_execution_limits_config_removes_empty_child_model_overrides() {
let mut config: serde_yaml::Value = serde_yaml::from_str(
r#"
delegation:
model: child-model
provider: openrouter
custom_flag: keep-delegation
"#,
)
.unwrap();
merge_hermes_execution_limits_config(
&mut config,
&json!({
"delegationModel": " ",
"delegationProvider": "",
}),
)
.unwrap();
assert!(config["delegation"]["model"].is_null());
assert!(config["delegation"]["provider"].is_null());
assert_eq!(
config["delegation"]["custom_flag"].as_str(),
Some("keep-delegation")

View File

@@ -192,6 +192,8 @@ const EXECUTION_LIMITS_DEFAULTS = {
delegationOrchestratorEnabled: true,
delegationSubagentAutoApprove: false,
delegationInheritMcpToolsets: true,
delegationModel: '',
delegationProvider: '',
}
const IO_SAFETY_DEFAULTS = {
@@ -1548,6 +1550,14 @@ export function render() {
<span class="hm-field-label">${t('engine.hermesExecutionLimitsDelegationMaxSpawnDepth')}</span>
<input id="hm-delegation-max-spawn-depth" class="hm-input" type="number" inputmode="numeric" min="1" max="3" step="1" value="${esc(executionLimitsValues.delegationMaxSpawnDepth)}" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesExecutionLimitsDelegationModel')}</span>
<input id="hm-delegation-model" class="hm-input" value="${esc(executionLimitsValues.delegationModel)}" placeholder="google/gemini-3-flash-preview" ${disabled ? 'disabled' : ''}>
</label>
<label class="hm-field">
<span class="hm-field-label">${t('engine.hermesExecutionLimitsDelegationProvider')}</span>
<input id="hm-delegation-provider" class="hm-input" value="${esc(executionLimitsValues.delegationProvider)}" placeholder="openrouter" ${disabled ? 'disabled' : ''}>
</label>
</div>
<div class="hm-config-check-grid">
<label class="hm-channel-check">
@@ -3402,6 +3412,8 @@ export function render() {
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',
delegationModel: el.querySelector('#hm-delegation-model')?.value || '',
delegationProvider: el.querySelector('#hm-delegation-provider')?.value || '',
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,

View File

@@ -561,10 +561,12 @@ export default {
hermesExecutionLimitsDelegationChildTimeout: _('每个子任务超时(秒)', 'Child timeout (s)', '每個子任務逾時(秒)'),
hermesExecutionLimitsDelegationMaxConcurrent: _('最大并发子任务', 'Max concurrent children', '最大並發子任務'),
hermesExecutionLimitsDelegationMaxSpawnDepth: _('委派深度上限', 'Spawn depth limit', '委派深度上限'),
hermesExecutionLimitsDelegationModel: _('子 Agent 模型覆盖(可选)', 'Child model override (optional)', '子 Agent 模型覆蓋(可選)'),
hermesExecutionLimitsDelegationProvider: _('子 Agent Provider 覆盖(可选)', 'Child provider override (optional)', '子 Agent Provider 覆蓋(可選)'),
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.', '預設會拒絕子任務危險命令審批,適合互動式和長跑任務。只有在完全信任無人值守環境時才啟用自動批准。'),
hermesExecutionLimitsFootnote: _('子 Agent 模型和 Provider 留空时继承父任务;只在需要降低成本、隔离慢模型或固定子任务路由时填写。默认会拒绝子任务危险命令审批,适合交互式和长跑任务。只有在完全信任无人值守环境时才开启自动批准。', 'Leave child model and provider blank to inherit the parent task. Fill them only to reduce cost, isolate slower models, or pin child-task routing. 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.', '子 Agent 模型和 Provider 留空時繼承父任務;只在需要降低成本、隔離慢模型或固定子任務路由時填寫。預設會拒絕子任務危險命令審批,適合互動式和長跑任務。只有在完全信任無人值守環境時才啟用自動批准。'),
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', '結構化設定'),

View File

@@ -288,6 +288,8 @@ test('Hermes 配置页会暴露执行与委派限制结构化配置字段', () =
'hm-delegation-child-timeout-seconds',
'hm-delegation-max-concurrent-children',
'hm-delegation-max-spawn-depth',
'hm-delegation-model',
'hm-delegation-provider',
'hm-delegation-orchestrator-enabled',
'hm-delegation-subagent-auto-approve',
'hm-delegation-inherit-mcp-toolsets',

View File

@@ -20,6 +20,8 @@ test('Hermes 执行与委派限制读取会提供上游默认值', () => {
delegationOrchestratorEnabled: true,
delegationSubagentAutoApprove: false,
delegationInheritMcpToolsets: true,
delegationModel: '',
delegationProvider: '',
})
})
@@ -38,6 +40,8 @@ test('Hermes 执行与委派限制读取会回显 YAML 字段', () => {
orchestrator_enabled: false,
subagent_auto_approve: true,
inherit_mcp_toolsets: false,
model: 'google/gemini-3-flash-preview',
provider: 'openrouter',
},
})
@@ -51,6 +55,8 @@ test('Hermes 执行与委派限制读取会回显 YAML 字段', () => {
assert.equal(values.delegationOrchestratorEnabled, false)
assert.equal(values.delegationSubagentAutoApprove, true)
assert.equal(values.delegationInheritMcpToolsets, false)
assert.equal(values.delegationModel, 'google/gemini-3-flash-preview')
assert.equal(values.delegationProvider, 'openrouter')
})
test('Hermes 执行与委派限制保存会保留未知字段并写入上游结构', () => {
@@ -77,6 +83,8 @@ test('Hermes 执行与委派限制保存会保留未知字段并写入上游结
delegationOrchestratorEnabled: false,
delegationSubagentAutoApprove: true,
delegationInheritMcpToolsets: false,
delegationModel: 'anthropic/claude-haiku-4.6',
delegationProvider: 'anthropic',
})
assert.deepEqual(next.model, { provider: 'anthropic' })
@@ -92,8 +100,25 @@ test('Hermes 执行与委派限制保存会保留未知字段并写入上游结
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.model, 'anthropic/claude-haiku-4.6')
assert.equal(next.delegation.provider, 'anthropic')
assert.equal(next.delegation.custom_flag, 'keep-delegation')
})
test('Hermes 执行与委派限制保存空子 Agent 模型覆盖会删除对应字段', () => {
const next = mergeHermesExecutionLimitsConfig({
delegation: {
model: 'child-model',
provider: 'openrouter',
custom_flag: 'keep-delegation',
},
}, {
delegationModel: ' ',
delegationProvider: '',
})
assert.equal(Object.hasOwn(next.delegation, 'model'), false)
assert.equal(Object.hasOwn(next.delegation, 'provider'), false)
assert.equal(next.delegation.custom_flag, 'keep-delegation')
})