diff --git a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts index 63bd715226..0b59371dd8 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts @@ -135,12 +135,17 @@ function createModuleFixture(options: { async updateRuntimePolicy( createMutation: (value: RuntimePolicy) => { kind: string; - value: RuntimePolicy["networkProxy"]; + value: unknown; }, ) { const mutation = createMutation(policy); if (mutation.kind === "set_network_proxy") { - policy = { ...policy, networkProxy: mutation.value }; + policy = { ...policy, networkProxy: mutation.value as RuntimePolicy["networkProxy"] }; + } else if (mutation.kind === "set_subagents") { + policy = { + ...policy, + subagents: mutation.value as RuntimePolicy["subagents"], + }; } policyRevision += 1; return { revision: policyRevision, policy }; @@ -265,6 +270,22 @@ test("runtime settings project credential status without a password value", asyn assert.equal("password" in settings.network.proxy, false); }); +test("subagent preset updates preserve the existing ad-hoc policy", async () => { + const fixture = createModuleFixture(); + const current = fixture.policy(); + const adHoc = { + enabled: true, + maxProfile: "local_read" as const, + connectionSlug: "worker-provider", + model: "gpt-5-mini", + }; + // Seed the host policy through the same mutation seam used by the module. + await fixture.module.update({ subagents: { presets: [], adHoc } }); + await fixture.module.update({ subagents: { presets: [] } }); + assert.deepEqual(fixture.policy().subagents, { presets: [], adHoc }); + assert.notDeepEqual(fixture.policy(), current); +}); + test("spread-back derived and legacy password fields never enter Runtime policy", async () => { const fixture = createModuleFixture({ configured: true }); diff --git a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts index 369ec810e0..f6dff080be 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -405,9 +405,12 @@ async function applyHostPatchWithoutLane( } } if (patch.subagents) { - await client.updateRuntimePolicy(() => ({ + await client.updateRuntimePolicy((policy) => ({ kind: "set_subagents", - value: patch.subagents!, + value: { + ...policy.subagents, + ...patch.subagents, + }, })); } return skippedCredentials; diff --git a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts index 6241da520e..e51715462a 100644 --- a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts @@ -29,6 +29,21 @@ type ProfileCopy = { }; export type SubagentSettingsCopy = { + adHoc: { + title: string; + description: string; + enabled: string; + enabledDescription: string; + profile: string; + profileDescription: string; + connection: string; + model: string; + thinking: string; + noConnection: string; + noModel: string; + save: string; + saveFailed: string; + }; section: { title: string; count(total: number): string; @@ -101,6 +116,21 @@ export type SubagentSettingsCopy = { const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { zh: { + adHoc: { + title: '临时子 Agent', + description: '明确启用后,主 Agent 才能创建一次性的任务角色。这里固定它可用的最高能力、连接和模型。', + enabled: '允许临时子 Agent', + enabledDescription: '关闭后,临时角色不会出现在 agent_list 中,也无法通过 agent_spawn 创建。', + profile: '最高能力 Profile', + profileDescription: '临时角色只能使用不高于此 Profile 的固定能力边界。', + connection: '模型连接', + model: '模型', + thinking: '思考级别', + noConnection: '请先在“模型”页启用一个模型连接。', + noModel: '所选连接没有已启用的模型。', + save: '保存临时策略', + saveFailed: '保存临时子 Agent 策略失败', + }, section: { title: '已批准的子 Agent', count: (total) => `共 ${total} 个配置`, @@ -183,6 +213,21 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { }, }, en: { + adHoc: { + title: 'Temporary subagent', + description: 'When explicitly enabled, the main agent may create one-off task roles. These settings fix their maximum capability, connection, and model.', + enabled: 'Allow temporary subagents', + enabledDescription: 'When off, the route is omitted from agent_list and agent_spawn cannot create it.', + profile: 'Maximum capability profile', + profileDescription: 'Temporary roles cannot exceed this fixed capability boundary.', + connection: 'Model connection', + model: 'Model', + thinking: 'Thinking level', + noConnection: 'Enable a model connection on the Models page first.', + noModel: 'The selected connection has no enabled models.', + save: 'Save temporary policy', + saveFailed: 'Failed to save temporary subagent policy', + }, section: { title: 'Approved subagents', count: (total) => `${total} presets`, diff --git a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx index abffa4fd13..8e88a2168f 100644 --- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx @@ -38,6 +38,7 @@ import { SUBAGENT_PRESET_DESCRIPTION_MAX_CHARS, SUBAGENT_PRESET_ID_MAX_CHARS, SUBAGENT_PRESET_NAME_MAX_CHARS, + type AdHocSubagentPolicy, type SubagentPreset, type SubagentProfile, } from '@maka/core/subagent-settings'; @@ -94,6 +95,10 @@ type SubagentEditorDraft = Omit & { thinkingLevel: ThinkingLevel | ''; }; +type AdHocSubagentPolicyDraft = Omit & { + thinkingLevel: ThinkingLevel | ''; +}; + export function SubagentSettingsPage(props: { settings: AppSettings; connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[]; @@ -158,7 +163,14 @@ export function SubagentSettingsPage(props: { ): Promise { setSaving(true); try { - const result = await props.onUpdate({ subagents: { presets: nextPresets } }); + const result = await props.onUpdate({ + subagents: { + presets: nextPresets, + ...(props.settings.subagents.adHoc + ? { adHoc: props.settings.subagents.adHoc } + : {}), + }, + }); if ( expectPresent !== undefined && !result.settings.subagents.presets.some((candidate) => candidate.id === expectPresent) @@ -236,6 +248,22 @@ export function SubagentSettingsPage(props: { return ( + { + setSaving(true); + try { + await props.onUpdate({ subagents: { presets, adHoc } }); + } catch (error) { + reportHostError(copy.adHoc.saveFailed, settingsActionErrorMessage(error, locale)); + } finally { + setSaving(false); + } + }} + /> ; +}) { + const locale = useUiLocale(); + const copy = getSubagentSettingsCopy(locale); + const usableConnections = useMemo( + () => props.connections.filter(isSelectableSubagentConnection), + [props.connections], + ); + const policy = props.policy; + const initialConnection = policy + ? props.connections.find((connection) => connection.slug === policy.connectionSlug) + : usableConnections[0]; + const initialModels = initialConnection ? offerableCatalogEntries(initialConnection) : []; + const [draft, setDraft] = useState(() => ({ + enabled: props.policy?.enabled ?? false, + maxProfile: props.policy?.maxProfile ?? 'local_read', + connectionSlug: props.policy?.connectionSlug ?? usableConnections[0]?.slug ?? '', + model: props.policy?.model ?? initialModels[0]?.id ?? '', + thinkingLevel: props.policy?.thinkingLevel ?? '', + })); + const selectedConnection = props.connections.find( + (connection) => connection.slug === draft.connectionSlug, + ); + const offerableModels = selectedConnection ? offerableCatalogEntries(selectedConnection) : []; + const thinkingLevels = + selectedConnection?.catalogEntries.find((entry) => entry.id === draft.model)?.thinkingLevels ?? + []; + const validRoute = Boolean( + selectedConnection && + isSelectableSubagentConnection(selectedConnection) && + offerableModels.some((entry) => entry.id === draft.model), + ); + const canSave = validRoute || (props.policy !== undefined && !draft.enabled); + + function selectConnection(connectionSlug: string): void { + const connection = usableConnections.find((candidate) => candidate.slug === connectionSlug); + const models = connection ? offerableCatalogEntries(connection) : []; + setDraft((current) => ({ + ...current, + connectionSlug, + model: models[0]?.id ?? '', + thinkingLevel: '', + })); + } + + function policyFromDraft(next: AdHocSubagentPolicyDraft): AdHocSubagentPolicy { + return { + enabled: next.enabled, + maxProfile: next.maxProfile, + connectionSlug: next.connectionSlug, + model: next.model, + ...(next.thinkingLevel ? { thinkingLevel: next.thinkingLevel } : {}), + }; + } + + return ( + + setDraft((current) => ({ ...current, enabled }))} + /> + )} + /> + ({ + value: profile, + label: copy.profiles[profile].label, + }))} + width="100%" + isDisabled={props.isSaving} + onChange={(maxProfile) => setDraft((current) => ({ + ...current, + maxProfile: maxProfile as SubagentProfile, + }))} + /> + )} + /> + ({ + value: connection.slug, + label: connection.name, + }))} + width="100%" + isDisabled={props.isSaving || usableConnections.length === 0} + disabledMessage={usableConnections.length === 0 ? copy.adHoc.noConnection : undefined} + onChange={selectConnection} + /> + )} + /> + ({ + value: entry.id, + label: entry.displayName?.trim() || entry.id, + }))} + width="100%" + isDisabled={props.isSaving || offerableModels.length === 0} + disabledMessage={offerableModels.length === 0 ? copy.adHoc.noModel : undefined} + onChange={(model) => setDraft((current) => ({ + ...current, + model, + thinkingLevel: '', + }))} + /> + )} + /> + {thinkingLevels.length > 0 ? ( + ({ value: level, label: copy.thinking[level] })), + ]} + width="100%" + isDisabled={props.isSaving} + onChange={(thinkingLevel) => setDraft((current) => ({ + ...current, + thinkingLevel: thinkingLevel as ThinkingLevel | '', + }))} + /> + )} + /> + ) : null} + +