Skip to content

Commit 31e3683

Browse files
author
testikun
committed
feat(runtime): add bounded foreground ad hoc child route
Generated-by: OpenAI Codex
1 parent 9d4002b commit 31e3683

17 files changed

Lines changed: 1053 additions & 41 deletions

apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,17 @@ function createModuleFixture(options: {
135135
async updateRuntimePolicy(
136136
createMutation: (value: RuntimePolicy) => {
137137
kind: string;
138-
value: RuntimePolicy["networkProxy"];
138+
value: unknown;
139139
},
140140
) {
141141
const mutation = createMutation(policy);
142142
if (mutation.kind === "set_network_proxy") {
143-
policy = { ...policy, networkProxy: mutation.value };
143+
policy = { ...policy, networkProxy: mutation.value as RuntimePolicy["networkProxy"] };
144+
} else if (mutation.kind === "set_subagents") {
145+
policy = {
146+
...policy,
147+
subagents: mutation.value as RuntimePolicy["subagents"],
148+
};
144149
}
145150
policyRevision += 1;
146151
return { revision: policyRevision, policy };
@@ -265,6 +270,22 @@ test("runtime settings project credential status without a password value", asyn
265270
assert.equal("password" in settings.network.proxy, false);
266271
});
267272

273+
test("subagent preset updates preserve the existing ad-hoc policy", async () => {
274+
const fixture = createModuleFixture();
275+
const current = fixture.policy();
276+
const adHoc = {
277+
enabled: true,
278+
maxProfile: "local_read" as const,
279+
connectionSlug: "worker-provider",
280+
model: "gpt-5-mini",
281+
};
282+
// Seed the host policy through the same mutation seam used by the module.
283+
await fixture.module.update({ subagents: { presets: [], adHoc } });
284+
await fixture.module.update({ subagents: { presets: [] } });
285+
assert.deepEqual(fixture.policy().subagents, { presets: [], adHoc });
286+
assert.notDeepEqual(fixture.policy(), current);
287+
});
288+
268289
test("spread-back derived and legacy password fields never enter Runtime policy", async () => {
269290
const fixture = createModuleFixture({ configured: true });
270291

apps/desktop/src/main/runtime-host-settings-ipc-main.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,9 +405,12 @@ async function applyHostPatchWithoutLane(
405405
}
406406
}
407407
if (patch.subagents) {
408-
await client.updateRuntimePolicy(() => ({
408+
await client.updateRuntimePolicy((policy) => ({
409409
kind: "set_subagents",
410-
value: patch.subagents!,
410+
value: {
411+
...policy.subagents,
412+
...patch.subagents,
413+
},
411414
}));
412415
}
413416
return skippedCredentials;

apps/desktop/src/renderer/locales/settings-subagents-copy.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ type ProfileCopy = {
2929
};
3030

3131
export type SubagentSettingsCopy = {
32+
adHoc: {
33+
title: string;
34+
description: string;
35+
enabled: string;
36+
enabledDescription: string;
37+
profile: string;
38+
profileDescription: string;
39+
connection: string;
40+
model: string;
41+
thinking: string;
42+
noConnection: string;
43+
noModel: string;
44+
save: string;
45+
saveFailed: string;
46+
};
3247
section: {
3348
title: string;
3449
count(total: number): string;
@@ -101,6 +116,21 @@ export type SubagentSettingsCopy = {
101116

102117
const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = {
103118
zh: {
119+
adHoc: {
120+
title: '临时子 Agent',
121+
description: '明确启用后,主 Agent 才能创建一次性的任务角色。这里固定它可用的最高能力、连接和模型。',
122+
enabled: '允许临时子 Agent',
123+
enabledDescription: '关闭后,临时角色不会出现在 agent_list 中,也无法通过 agent_spawn 创建。',
124+
profile: '最高能力 Profile',
125+
profileDescription: '临时角色只能使用不高于此 Profile 的固定能力边界。',
126+
connection: '模型连接',
127+
model: '模型',
128+
thinking: '思考级别',
129+
noConnection: '请先在“模型”页启用一个模型连接。',
130+
noModel: '所选连接没有已启用的模型。',
131+
save: '保存临时策略',
132+
saveFailed: '保存临时子 Agent 策略失败',
133+
},
104134
section: {
105135
title: '已批准的子 Agent',
106136
count: (total) => `共 ${total} 个配置`,
@@ -183,6 +213,21 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = {
183213
},
184214
},
185215
en: {
216+
adHoc: {
217+
title: 'Temporary subagent',
218+
description: 'When explicitly enabled, the main agent may create one-off task roles. These settings fix their maximum capability, connection, and model.',
219+
enabled: 'Allow temporary subagents',
220+
enabledDescription: 'When off, the route is omitted from agent_list and agent_spawn cannot create it.',
221+
profile: 'Maximum capability profile',
222+
profileDescription: 'Temporary roles cannot exceed this fixed capability boundary.',
223+
connection: 'Model connection',
224+
model: 'Model',
225+
thinking: 'Thinking level',
226+
noConnection: 'Enable a model connection on the Models page first.',
227+
noModel: 'The selected connection has no enabled models.',
228+
save: 'Save temporary policy',
229+
saveFailed: 'Failed to save temporary subagent policy',
230+
},
186231
section: {
187232
title: 'Approved subagents',
188233
count: (total) => `${total} presets`,

apps/desktop/src/renderer/settings/subagent-settings-page.tsx

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
SUBAGENT_PRESET_DESCRIPTION_MAX_CHARS,
3939
SUBAGENT_PRESET_ID_MAX_CHARS,
4040
SUBAGENT_PRESET_NAME_MAX_CHARS,
41+
type AdHocSubagentPolicy,
4142
type SubagentPreset,
4243
type SubagentProfile,
4344
} from '@maka/core/subagent-settings';
@@ -94,6 +95,10 @@ type SubagentEditorDraft = Omit<SubagentPreset, 'thinkingLevel'> & {
9495
thinkingLevel: ThinkingLevel | '';
9596
};
9697

98+
type AdHocSubagentPolicyDraft = Omit<AdHocSubagentPolicy, 'thinkingLevel'> & {
99+
thinkingLevel: ThinkingLevel | '';
100+
};
101+
97102
export function SubagentSettingsPage(props: {
98103
settings: AppSettings;
99104
connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[];
@@ -158,7 +163,14 @@ export function SubagentSettingsPage(props: {
158163
): Promise<boolean> {
159164
setSaving(true);
160165
try {
161-
const result = await props.onUpdate({ subagents: { presets: nextPresets } });
166+
const result = await props.onUpdate({
167+
subagents: {
168+
presets: nextPresets,
169+
...(props.settings.subagents.adHoc
170+
? { adHoc: props.settings.subagents.adHoc }
171+
: {}),
172+
},
173+
});
162174
if (
163175
expectPresent !== undefined &&
164176
!result.settings.subagents.presets.some((candidate) => candidate.id === expectPresent)
@@ -236,6 +248,22 @@ export function SubagentSettingsPage(props: {
236248

237249
return (
238250
<SettingsPage>
251+
<AdHocSubagentPolicySection
252+
key={JSON.stringify(props.settings.subagents.adHoc ?? null)}
253+
policy={props.settings.subagents.adHoc}
254+
connections={props.connections}
255+
isSaving={saving}
256+
onSave={async (adHoc) => {
257+
setSaving(true);
258+
try {
259+
await props.onUpdate({ subagents: { presets, adHoc } });
260+
} catch (error) {
261+
reportHostError(copy.adHoc.saveFailed, settingsActionErrorMessage(error, locale));
262+
} finally {
263+
setSaving(false);
264+
}
265+
}}
266+
/>
239267
<SettingsSection
240268
title={copy.section.title}
241269
/* The 「/ 64」 was a system ceiling nobody can raise or act on;
@@ -331,6 +359,176 @@ export function SubagentSettingsPage(props: {
331359
);
332360
}
333361

362+
function AdHocSubagentPolicySection(props: {
363+
policy: AdHocSubagentPolicy | undefined;
364+
connections: readonly (LlmConnection & HostResolvedConnectionCatalog)[];
365+
isSaving: boolean;
366+
onSave(policy: AdHocSubagentPolicy): Promise<void>;
367+
}) {
368+
const locale = useUiLocale();
369+
const copy = getSubagentSettingsCopy(locale);
370+
const usableConnections = useMemo(
371+
() => props.connections.filter(isSelectableSubagentConnection),
372+
[props.connections],
373+
);
374+
const policy = props.policy;
375+
const initialConnection = policy
376+
? props.connections.find((connection) => connection.slug === policy.connectionSlug)
377+
: usableConnections[0];
378+
const initialModels = initialConnection ? offerableCatalogEntries(initialConnection) : [];
379+
const [draft, setDraft] = useState<AdHocSubagentPolicyDraft>(() => ({
380+
enabled: props.policy?.enabled ?? false,
381+
maxProfile: props.policy?.maxProfile ?? 'local_read',
382+
connectionSlug: props.policy?.connectionSlug ?? usableConnections[0]?.slug ?? '',
383+
model: props.policy?.model ?? initialModels[0]?.id ?? '',
384+
thinkingLevel: props.policy?.thinkingLevel ?? '',
385+
}));
386+
const selectedConnection = props.connections.find(
387+
(connection) => connection.slug === draft.connectionSlug,
388+
);
389+
const offerableModels = selectedConnection ? offerableCatalogEntries(selectedConnection) : [];
390+
const thinkingLevels =
391+
selectedConnection?.catalogEntries.find((entry) => entry.id === draft.model)?.thinkingLevels ??
392+
[];
393+
const validRoute = Boolean(
394+
selectedConnection &&
395+
isSelectableSubagentConnection(selectedConnection) &&
396+
offerableModels.some((entry) => entry.id === draft.model),
397+
);
398+
const canSave = validRoute || (props.policy !== undefined && !draft.enabled);
399+
400+
function selectConnection(connectionSlug: string): void {
401+
const connection = usableConnections.find((candidate) => candidate.slug === connectionSlug);
402+
const models = connection ? offerableCatalogEntries(connection) : [];
403+
setDraft((current) => ({
404+
...current,
405+
connectionSlug,
406+
model: models[0]?.id ?? '',
407+
thinkingLevel: '',
408+
}));
409+
}
410+
411+
function policyFromDraft(next: AdHocSubagentPolicyDraft): AdHocSubagentPolicy {
412+
return {
413+
enabled: next.enabled,
414+
maxProfile: next.maxProfile,
415+
connectionSlug: next.connectionSlug,
416+
model: next.model,
417+
...(next.thinkingLevel ? { thinkingLevel: next.thinkingLevel } : {}),
418+
};
419+
}
420+
421+
return (
422+
<SettingsSection title={copy.adHoc.title} description={copy.adHoc.description}>
423+
<SettingsRow
424+
label={copy.adHoc.enabled}
425+
description={copy.adHoc.enabledDescription}
426+
align="start"
427+
end={(
428+
<Switch
429+
label={copy.adHoc.enabled}
430+
isLabelHidden
431+
value={draft.enabled}
432+
isDisabled={props.isSaving || (!validRoute && !draft.enabled)}
433+
onChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}
434+
/>
435+
)}
436+
/>
437+
<SettingsRow
438+
label={copy.adHoc.profile}
439+
description={copy.adHoc.profileDescription}
440+
end={(
441+
<Selector
442+
label={copy.adHoc.profile}
443+
isLabelHidden
444+
value={draft.maxProfile}
445+
options={(Object.keys(copy.profiles) as SubagentProfile[]).map((profile) => ({
446+
value: profile,
447+
label: copy.profiles[profile].label,
448+
}))}
449+
width="100%"
450+
isDisabled={props.isSaving}
451+
onChange={(maxProfile) => setDraft((current) => ({
452+
...current,
453+
maxProfile: maxProfile as SubagentProfile,
454+
}))}
455+
/>
456+
)}
457+
/>
458+
<SettingsRow
459+
label={copy.adHoc.connection}
460+
end={(
461+
<Selector
462+
label={copy.adHoc.connection}
463+
isLabelHidden
464+
value={draft.connectionSlug}
465+
options={usableConnections.map((connection) => ({
466+
value: connection.slug,
467+
label: connection.name,
468+
}))}
469+
width="100%"
470+
isDisabled={props.isSaving || usableConnections.length === 0}
471+
disabledMessage={usableConnections.length === 0 ? copy.adHoc.noConnection : undefined}
472+
onChange={selectConnection}
473+
/>
474+
)}
475+
/>
476+
<SettingsRow
477+
label={copy.adHoc.model}
478+
end={(
479+
<Selector
480+
label={copy.adHoc.model}
481+
isLabelHidden
482+
value={draft.model}
483+
options={offerableModels.map((entry) => ({
484+
value: entry.id,
485+
label: entry.displayName?.trim() || entry.id,
486+
}))}
487+
width="100%"
488+
isDisabled={props.isSaving || offerableModels.length === 0}
489+
disabledMessage={offerableModels.length === 0 ? copy.adHoc.noModel : undefined}
490+
onChange={(model) => setDraft((current) => ({
491+
...current,
492+
model,
493+
thinkingLevel: '',
494+
}))}
495+
/>
496+
)}
497+
/>
498+
{thinkingLevels.length > 0 ? (
499+
<SettingsRow
500+
label={copy.adHoc.thinking}
501+
end={(
502+
<Selector
503+
label={copy.adHoc.thinking}
504+
isLabelHidden
505+
value={draft.thinkingLevel}
506+
options={[
507+
{ value: '', label: copy.editor.defaultThinking },
508+
...thinkingLevels.map((level) => ({ value: level, label: copy.thinking[level] })),
509+
]}
510+
width="100%"
511+
isDisabled={props.isSaving}
512+
onChange={(thinkingLevel) => setDraft((current) => ({
513+
...current,
514+
thinkingLevel: thinkingLevel as ThinkingLevel | '',
515+
}))}
516+
/>
517+
)}
518+
/>
519+
) : null}
520+
<HStack gap={2} wrap="wrap">
521+
<Button
522+
variant="primary"
523+
label={copy.adHoc.save}
524+
isDisabled={props.isSaving || !canSave}
525+
onClick={() => void props.onSave(policyFromDraft(draft))}
526+
/>
527+
</HStack>
528+
</SettingsSection>
529+
);
530+
}
531+
334532
function SubagentPresetEditor(props: {
335533
preset: SubagentPreset | null;
336534
presets: readonly SubagentPreset[];

packages/core/src/__tests__/runtime-policy-codec.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,29 @@ test('keeps user-approved subagent presets canonical in Runtime Policy', () => {
101101
);
102102
});
103103

104+
test('round-trips an explicitly enabled ad-hoc child ceiling without model authority fields', () => {
105+
const policy = {
106+
...createDefaultRuntimePolicy(),
107+
subagents: {
108+
presets: [],
109+
adHoc: {
110+
enabled: true,
111+
maxProfile: 'local_read' as const,
112+
connectionSlug: 'openrouter',
113+
model: 'openrouter/free',
114+
},
115+
},
116+
};
117+
assert.deepEqual(decodeCanonicalRuntimePolicy(policy).subagents.adHoc, policy.subagents.adHoc);
118+
assert.deepEqual(
119+
normalizeRuntimePolicyMutation({
120+
expectedRevision: 4,
121+
operation: { kind: 'set_subagents', value: policy.subagents },
122+
}),
123+
{ expectedRevision: 4, operation: { kind: 'set_subagents', value: policy.subagents } },
124+
);
125+
});
126+
104127
test('normalizes the explicit Git Bash preference and rejects arbitrary shell kinds', () => {
105128
assert.deepEqual(
106129
normalizeRuntimePolicyMutation({

0 commit comments

Comments
 (0)