From 31e368317062f2ae48ecf8b56eb21cb5e0c8d42c Mon Sep 17 00:00:00 2001 From: testikun Date: Thu, 3 Sep 2026 14:56:25 +0800 Subject: [PATCH 1/2] feat(runtime): add bounded foreground ad hoc child route Generated-by: OpenAI Codex --- .../runtime-host-settings-ipc-main.test.ts | 25 ++- .../main/runtime-host-settings-ipc-main.ts | 7 +- .../locales/settings-subagents-copy.ts | 45 ++++ .../settings/subagent-settings-page.tsx | 200 +++++++++++++++++- .../__tests__/runtime-policy-codec.test.ts | 23 ++ packages/core/src/__tests__/settings.test.ts | 49 +++++ packages/core/src/settings.ts | 2 +- packages/core/src/subagent-settings.ts | 121 ++++++++++- .../src/server/child-agent-composition.ts | 1 + .../src/server/execution-composition.ts | 2 + .../configured-subagent-catalog.test.ts | 49 +++++ .../src/__tests__/session-manager.test.ts | 171 +++++++++++++++ .../src/__tests__/subagent-tools.test.ts | 123 +++++++++++ .../src/configured-subagent-catalog.ts | 39 +++- packages/runtime/src/session-manager.ts | 106 ++++++++-- packages/runtime/src/subagent-tools.ts | 125 ++++++++++- packages/runtime/src/tool-runtime.ts | 6 + 17 files changed, 1053 insertions(+), 41 deletions(-) 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} + +