Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 });

Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/main/runtime-host-settings-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/src/renderer/locales/settings-subagents-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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} 个配置`,
Expand Down Expand Up @@ -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`,
Expand Down
200 changes: 199 additions & 1 deletion apps/desktop/src/renderer/settings/subagent-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -94,6 +95,10 @@ type SubagentEditorDraft = Omit<SubagentPreset, 'thinkingLevel'> & {
thinkingLevel: ThinkingLevel | '';
};

type AdHocSubagentPolicyDraft = Omit<AdHocSubagentPolicy, 'thinkingLevel'> & {
thinkingLevel: ThinkingLevel | '';
};

export function SubagentSettingsPage(props: {
settings: AppSettings;
connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[];
Expand Down Expand Up @@ -158,7 +163,14 @@ export function SubagentSettingsPage(props: {
): Promise<boolean> {
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)
Expand Down Expand Up @@ -236,6 +248,22 @@ export function SubagentSettingsPage(props: {

return (
<SettingsPage>
<AdHocSubagentPolicySection
key={JSON.stringify(props.settings.subagents.adHoc ?? null)}
policy={props.settings.subagents.adHoc}
connections={props.connections}
isSaving={saving}
onSave={async (adHoc) => {
setSaving(true);
try {
await props.onUpdate({ subagents: { presets, adHoc } });
} catch (error) {
reportHostError(copy.adHoc.saveFailed, settingsActionErrorMessage(error, locale));
} finally {
setSaving(false);
}
}}
/>
<SettingsSection
title={copy.section.title}
/* The 「/ 64」 was a system ceiling nobody can raise or act on;
Expand Down Expand Up @@ -331,6 +359,176 @@ export function SubagentSettingsPage(props: {
);
}

function AdHocSubagentPolicySection(props: {
policy: AdHocSubagentPolicy | undefined;
connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[];
isSaving: boolean;
onSave(policy: AdHocSubagentPolicy): Promise<void>;
}) {
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<AdHocSubagentPolicyDraft>(() => ({
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 (
<SettingsSection title={copy.adHoc.title} description={copy.adHoc.description}>
<SettingsRow
label={copy.adHoc.enabled}
description={copy.adHoc.enabledDescription}
align="start"
end={(
<Switch
label={copy.adHoc.enabled}
isLabelHidden
value={draft.enabled}
isDisabled={props.isSaving || (!validRoute && !draft.enabled)}
onChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}
/>
)}
/>
<SettingsRow
label={copy.adHoc.profile}
description={copy.adHoc.profileDescription}
end={(
<Selector
label={copy.adHoc.profile}
isLabelHidden
value={draft.maxProfile}
options={(Object.keys(copy.profiles) as SubagentProfile[]).map((profile) => ({
value: profile,
label: copy.profiles[profile].label,
}))}
width="100%"
isDisabled={props.isSaving}
onChange={(maxProfile) => setDraft((current) => ({
...current,
maxProfile: maxProfile as SubagentProfile,
}))}
/>
)}
/>
<SettingsRow
label={copy.adHoc.connection}
end={(
<Selector
label={copy.adHoc.connection}
isLabelHidden
value={draft.connectionSlug}
options={usableConnections.map((connection) => ({
value: connection.slug,
label: connection.name,
}))}
width="100%"
isDisabled={props.isSaving || usableConnections.length === 0}
disabledMessage={usableConnections.length === 0 ? copy.adHoc.noConnection : undefined}
onChange={selectConnection}
/>
)}
/>
<SettingsRow
label={copy.adHoc.model}
end={(
<Selector
label={copy.adHoc.model}
isLabelHidden
value={draft.model}
options={offerableModels.map((entry) => ({
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 ? (
<SettingsRow
label={copy.adHoc.thinking}
end={(
<Selector
label={copy.adHoc.thinking}
isLabelHidden
value={draft.thinkingLevel}
options={[
{ value: '', label: copy.editor.defaultThinking },
...thinkingLevels.map((level) => ({ value: level, label: copy.thinking[level] })),
]}
width="100%"
isDisabled={props.isSaving}
onChange={(thinkingLevel) => setDraft((current) => ({
...current,
thinkingLevel: thinkingLevel as ThinkingLevel | '',
}))}
/>
)}
/>
) : null}
<HStack gap={2} wrap="wrap">
<Button
variant="primary"
label={copy.adHoc.save}
isDisabled={props.isSaving || !canSave}
onClick={() => void props.onSave(policyFromDraft(draft))}
/>
</HStack>
</SettingsSection>
);
}

function SubagentPresetEditor(props: {
preset: SubagentPreset | null;
presets: readonly SubagentPreset[];
Expand Down
Binary file added docs/images/pr/subagents-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/pr/subagents-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions packages/core/src/__tests__/runtime-policy-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ test('keeps user-approved subagent presets canonical in Runtime Policy', () => {
);
});

test('round-trips an explicitly enabled ad-hoc child ceiling without model authority fields', () => {
const policy = {
...createDefaultRuntimePolicy(),
subagents: {
presets: [],
adHoc: {
enabled: true,
maxProfile: 'local_read' as const,
connectionSlug: 'openrouter',
model: 'openrouter/free',
},
},
};
assert.deepEqual(decodeCanonicalRuntimePolicy(policy).subagents.adHoc, policy.subagents.adHoc);
assert.deepEqual(
normalizeRuntimePolicyMutation({
expectedRevision: 4,
operation: { kind: 'set_subagents', value: policy.subagents },
}),
{ expectedRevision: 4, operation: { kind: 'set_subagents', value: policy.subagents } },
);
});

test('normalizes the explicit Git Bash preference and rejects arbitrary shell kinds', () => {
assert.deepEqual(
normalizeRuntimePolicyMutation({
Expand Down
Loading
Loading