Skip to content

Commit e731b91

Browse files
committed
feat(config): canonical subagent model policy with a dedicated endpoint
The subagent model configuration was validated only at session start, so any write could persist an unresolvable policy, and the routing code read the legacy secondary_model fields directly in several places. - policy.ts: LegacySecondaryModelConfig (disk / legacy REST) versus CanonicalSubagentModelPolicy (inherit | default | pool | force); normalizeLegacySecondaryModel covers every legacy field combination, persisted inherit is the absent section, and canonical values never carry legacy fields. Pure validateSubagentModelPolicy with a resolveModel context; prospectiveModelView builds that context from a previewed configuration. - ISubagentModelPolicyService (App scope): get() with a strong resourceVersion hash, getEffective() (effective policy is inherit while the feature is disabled), set/clear with an expectedVersion guard, prepareLegacyMutation for coordinators, resolveRevision that hashes only ambient routing inputs; routeDecisionFingerprint covers request intent separately. - IConfigService.previewReplaceSections returns the effective configuration a replacement would yield (defaults, env bindings, overlays, memory) with no write, no event, no registry mutation. - POST /config validates secondary_model through the policy service against the prospective configuration of the same request; provider discovery routes its cascaded section through the same preparation. - GET/PUT/DELETE /config/subagent-model-policy with a strong ETag and If-Match (412 on a stale version). - The runtime readers in configSection.ts derive from the canonical policy; an import-boundary test keeps legacy symbols inside the adapter and the section writable only through the policy service.
1 parent 9cf318e commit e731b91

22 files changed

Lines changed: 1968 additions & 117 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Add a subagent model policy setting with inherit, default, pool, and force modes that rejects models that are not configured.

packages/agent-core-v2/src/app/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export interface IConfigService {
205205
sections: Readonly<Record<string, unknown>>,
206206
target?: ConfigTarget,
207207
): Promise<void>;
208+
previewReplaceSections(sections: Readonly<Record<string, unknown>>): ResolvedConfig;
208209
reload(): Promise<void>;
209210
diagnostics(): readonly ConfigDiagnostic[];
210211
}

packages/agent-core-v2/src/app/config/configService.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,24 @@ export class ConfigService extends Disposable implements IConfigService {
487487
});
488488
}
489489

490+
previewReplaceSections(sections: Readonly<Record<string, unknown>>): ResolvedConfig {
491+
const stagedRaw: ResolvedConfig = { ...this.raw };
492+
const stagedRawSnake = cloneRecord(this.rawSnake);
493+
for (const domain of Object.keys(sections)) {
494+
const value = sections[domain] === null ? undefined : sections[domain];
495+
const stripped = this.stripEnv(domain, value, stagedRaw, stagedRawSnake);
496+
if (stripped === undefined) {
497+
delete stagedRaw[domain];
498+
} else {
499+
stagedRaw[domain] = this.registry.validate(domain, stripped);
500+
}
501+
}
502+
const next: ResolvedConfig = { ...this.buildValidated(stagedRaw, false) };
503+
this.applySectionEnvBindings(next, false);
504+
this.applyEnvOverlay(next, false);
505+
return { ...next, ...this.memory };
506+
}
507+
490508
private stripEnv(
491509
domain: string,
492510
value: unknown,
@@ -599,12 +617,13 @@ export class ConfigService extends Disposable implements IConfigService {
599617
}
600618
}
601619

602-
private buildValidated(raw: ResolvedConfig): ResolvedConfig {
620+
private buildValidated(raw: ResolvedConfig, report = true): ResolvedConfig {
603621
const validated: ResolvedConfig = {};
604622
for (const [domain, value] of Object.entries(raw)) {
605623
try {
606624
validated[domain] = this.registry.validate(domain, value);
607625
} catch (error) {
626+
if (!report) continue;
608627
this.pushDiagnostic({
609628
domain,
610629
severity: 'warning',

packages/agent-core-v2/src/app/config/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const ConfigErrors = {
55
codes: {
66
CONFIG_INVALID: CONFIG_INVALID_ERROR_CODE,
77
CONFIG_PERSIST_BLOCKED: 'config.persist_blocked',
8+
CONFIG_VERSION_CONFLICT: 'config.version_conflict',
89
},
910
} as const satisfies ErrorDomain;
1011

packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,8 @@ import {
2525
PROVIDERS_SECTION,
2626
THINKING_SECTION,
2727
} from './configSection';
28-
import {
29-
SECONDARY_MODEL_SECTION,
30-
} from '#/session/subagent/configSection';
28+
import { prospectiveModelView, SECONDARY_MODEL_SECTION } from '#/session/subagent/policy';
29+
import { ISubagentModelPolicyService } from '#/session/subagent/subagentModelPolicy';
3130
import {
3231
IProviderDiscoveryService,
3332
ModelCatalogChanged,
@@ -54,6 +53,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
5453
@IConfigService private readonly config: IConfigService,
5554
@IEventService private readonly events: IEventService,
5655
@IAgentIdentity private readonly identity: IAgentIdentity,
56+
@ISubagentModelPolicyService private readonly subagentPolicy: ISubagentModelPolicyService,
5757
) {}
5858

5959
refreshProviderModels(
@@ -216,7 +216,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
216216
sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking;
217217
}
218218
if ('secondaryModel' in patch) {
219-
sections[SECONDARY_MODEL_SECTION] = patch.secondaryModel;
219+
const preview = this.config.previewReplaceSections(sections);
220+
sections[SECONDARY_MODEL_SECTION] = this.subagentPolicy.prepareLegacyMutation(
221+
patch.secondaryModel,
222+
prospectiveModelView(preview[PROVIDERS_SECTION], preview[MODELS_SECTION]),
223+
).section;
220224
}
221225
await this.config.replaceSections(sections);
222226
return {

packages/agent-core-v2/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,9 @@ export * from '#/session/subagent/spawn';
484484
import '#/session/subagent/flag';
485485
export * from '#/session/subagent/subagentModelsValidation';
486486
import '#/session/subagent/subagentModelsValidationService';
487+
export * from '#/session/subagent/policy';
488+
export * from '#/session/subagent/subagentModelPolicy';
489+
import '#/session/subagent/subagentModelPolicyService';
487490
export * from '#/agent/tools/agent/subagent-task';
488491
export { AGENT_RUN_PROMPT_ORIGIN } from '#/session/subagent/runAgentTurn';
489492
export * from '#/session/subagent/mirrorAgentRun';

packages/agent-core-v2/src/session/subagent/configSection.ts

Lines changed: 67 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -18,34 +18,43 @@ import {
1818
} from '#/kosong/model/thinking';
1919

2020
import { SECONDARY_MODEL_FLAG_ID } from './flag';
21+
import {
22+
type CanonicalSubagentModelPolicy,
23+
INHERIT_SUBAGENT_MODEL_POLICY,
24+
type LegacySecondaryModelConfig,
25+
LegacySecondaryModelConfigSchema,
26+
normalizeLegacySecondaryModel,
27+
normalizeLegacySecondaryModelOrInherit,
28+
PRIMARY_SUBAGENT_MODEL_CHOICE,
29+
SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE,
30+
SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE,
31+
SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE,
32+
SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE,
33+
SECONDARY_MODEL_SECTION,
34+
subagentPolicyModelChoices,
35+
validateSubagentModelPolicy,
36+
} from './policy';
37+
38+
export {
39+
PRIMARY_SUBAGENT_MODEL_CHOICE,
40+
SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE,
41+
SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE,
42+
SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE,
43+
SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE,
44+
SECONDARY_MODEL_SECTION,
45+
};
2146

2247
export const SUBAGENT_SECTION = 'subagent';
23-
export const SECONDARY_MODEL_SECTION = 'secondaryModel';
2448

2549
export const SubagentConfigSchema = z.object({
2650
timeoutMs: z.number().int().min(0).optional(),
2751
});
2852

2953
export type SubagentConfig = z.infer<typeof SubagentConfigSchema>;
3054

31-
export const SecondaryModelConfigSchema = z.object({
32-
defaultModel: z.string().min(1).optional(),
33-
models: z.record(z.string(), z.string()).optional(),
34-
force: z.boolean().optional(),
35-
model: z.string().min(1).optional(),
36-
maxContextSize: z.number().int().min(1).optional(),
37-
maxInputSize: z.number().int().min(1).optional(),
38-
maxOutputSize: z.number().int().min(1).optional(),
39-
capabilities: z.array(z.string()).optional(),
40-
displayName: z.string().optional(),
41-
reasoningKey: z.string().optional(),
42-
adaptiveThinking: z.boolean().optional(),
43-
supportEfforts: z.array(z.string()).optional(),
44-
defaultEffort: z.string().optional(),
45-
offEffort: z.string().optional(),
46-
});
55+
export const SecondaryModelConfigSchema = LegacySecondaryModelConfigSchema;
4756

48-
export type SecondaryModelConfig = z.infer<typeof SecondaryModelConfigSchema>;
57+
export type SecondaryModelConfig = LegacySecondaryModelConfig;
4958

5059
export const DEFAULT_SUBAGENT_TIMEOUT_MS = 2 * 60 * 60 * 1000;
5160

@@ -80,35 +89,32 @@ export function resolveSubagentTimeoutMs(config: IConfigService): number {
8089
);
8190
}
8291

83-
export const PRIMARY_SUBAGENT_MODEL_CHOICE = 'primary';
84-
8592
export interface SubagentModelPool {
8693
readonly defaultModel?: string;
8794
readonly models: Record<string, string>;
8895
}
8996

90-
export function resolveSubagentModelPool(config: IConfigService): SubagentModelPool | undefined {
91-
const section = config.get<SecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION);
92-
if (section?.models !== undefined) {
93-
return { defaultModel: section.defaultModel, models: section.models };
94-
}
95-
if (section?.defaultModel !== undefined) {
96-
return { defaultModel: section.defaultModel, models: { [section.defaultModel]: '' } };
97-
}
98-
if (section?.model !== undefined) {
99-
return { defaultModel: section.model, models: { [section.model]: '' } };
100-
}
101-
return undefined;
97+
function configuredPolicy(config: IConfigService): CanonicalSubagentModelPolicy {
98+
return normalizeLegacySecondaryModel(
99+
config.get<LegacySecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION),
100+
);
102101
}
103102

104-
export const SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE =
105-
'[secondary_model].default_model is required when [secondary_model].force is set';
103+
function configuredPolicyOrInherit(config: IConfigService): CanonicalSubagentModelPolicy {
104+
return normalizeLegacySecondaryModelOrInherit(
105+
config.get<LegacySecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION),
106+
);
107+
}
106108

107-
export const SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE =
108-
'[secondary_model].force cannot be combined with [secondary_model.models]: the pool table only exists to offer the main agent a choice, and force removes that choice';
109+
export function resolveSubagentModelPool(config: IConfigService): SubagentModelPool | undefined {
110+
const policy = configuredPolicyOrInherit(config);
111+
const models = subagentPolicyModelChoices(policy);
112+
if (policy.mode === 'inherit' || models === undefined) return undefined;
113+
return { defaultModel: policy.defaultModel, models: { ...models } };
114+
}
109115

110116
export function isSubagentModelForced(config: IConfigService): boolean {
111-
return config.get<SecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION)?.force === true;
117+
return configuredPolicyOrInherit(config).mode === 'force';
112118
}
113119

114120
export function exposesSubagentModelChoice(config: IConfigService, flags: IFlagService): boolean {
@@ -117,10 +123,14 @@ export function exposesSubagentModelChoice(config: IConfigService, flags: IFlagS
117123
return resolveSubagentModelPool(config) !== undefined;
118124
}
119125

120-
export const SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE =
121-
'[secondary_model].default_model is required when [secondary_model.models] is configured';
122-
123-
export const SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE = `[secondary_model.models] key "${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved: it always binds the caller's own model. Rename the pool entry.`;
126+
function catalogValidationContext(modelCatalog: IModelCatalog) {
127+
return {
128+
resolveModel(alias: string) {
129+
const model = modelCatalog.get(alias);
130+
return { id: model.id, defaultEffort: model.defaultEffort, supportEfforts: model.supportEfforts };
131+
},
132+
};
133+
}
124134

125135
export function assertValidSubagentModelPool(
126136
pool: SubagentModelPool,
@@ -135,30 +145,15 @@ export function assertValidSubagentModelPool(
135145
},
136146
});
137147
}
138-
const aliases = Object.keys(pool.models);
139148
if (pool.defaultModel === undefined) {
140149
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, {
141150
details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' },
142151
});
143152
}
144-
if (!Object.hasOwn(pool.models, pool.defaultModel)) {
145-
throw new Error2(
146-
ErrorCodes.CONFIG_INVALID,
147-
`[secondary_model].default_model "${pool.defaultModel}" is not a [secondary_model.models] key. Available models: ${aliases.join(', ')}.`,
148-
{ details: { model: pool.defaultModel, availableModels: aliases } },
149-
);
150-
}
151-
for (const alias of aliases) {
152-
try {
153-
modelCatalog.get(alias);
154-
} catch (error) {
155-
throw new Error2(
156-
ErrorCodes.CONFIG_INVALID,
157-
`[secondary_model.models] entry "${alias}" could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
158-
{ cause: error, details: { model: alias } },
159-
);
160-
}
161-
}
153+
validateSubagentModelPolicy(
154+
{ mode: 'pool', defaultModel: pool.defaultModel, models: { ...pool.models } },
155+
catalogValidationContext(modelCatalog),
156+
);
162157
}
163158

164159
export function assertValidSubagentModelConfig(
@@ -167,21 +162,7 @@ export function assertValidSubagentModelConfig(
167162
modelCatalog: IModelCatalog,
168163
): void {
169164
if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return;
170-
const section = config.get<SecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION);
171-
if (section?.force === true) {
172-
if (section.models !== undefined) {
173-
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, {
174-
details: { section: SECONDARY_MODEL_SECTION, field: 'force' },
175-
});
176-
}
177-
if (section.defaultModel === undefined && section.model === undefined) {
178-
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, {
179-
details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' },
180-
});
181-
}
182-
}
183-
const pool = resolveSubagentModelPool(config);
184-
if (pool !== undefined) assertValidSubagentModelPool(pool, modelCatalog);
165+
validateSubagentModelPolicy(configuredPolicy(config), catalogValidationContext(modelCatalog));
185166
}
186167

187168
export function cascadeSubagentModelPool(
@@ -215,33 +196,21 @@ export function resolveSubagentBinding(
215196
requested?: string,
216197
): { model: string; thinking?: string } {
217198
const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID);
218-
const section = config.get<SecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION);
219-
if (enabled && section?.force === true) {
220-
if (section.models !== undefined) {
221-
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, {
222-
details: { section: SECONDARY_MODEL_SECTION, field: 'force' },
223-
});
224-
}
225-
const forcedModel = section.defaultModel ?? section.model;
226-
if (forcedModel === undefined) {
227-
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, {
228-
details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' },
229-
});
230-
}
199+
const policy = enabled ? configuredPolicy(config) : INHERIT_SUBAGENT_MODEL_POLICY;
200+
if (policy.mode === 'force') {
231201
if (requested !== undefined) {
232202
throw new Error2(
233203
ErrorCodes.CONFIG_INVALID,
234-
`Invalid model "${requested}": [secondary_model].force is set, so every subagent binds "${forcedModel}" (omit the model parameter).`,
204+
`Invalid model "${requested}": [secondary_model].force is set, so every subagent binds "${policy.defaultModel}" (omit the model parameter).`,
235205
{ details: { model: requested } },
236206
);
237207
}
238-
return { model: forcedModel, thinking: section.defaultEffort };
208+
return { model: policy.defaultModel, thinking: policy.defaultEffort };
239209
}
240210
if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) {
241211
return { model: own.modelAlias, thinking: own.thinkingLevel };
242212
}
243-
const pool = enabled ? resolveSubagentModelPool(config) : undefined;
244-
if (pool === undefined) {
213+
if (policy.mode === 'inherit') {
245214
if (requested !== undefined) {
246215
throw new Error2(
247216
ErrorCodes.CONFIG_INVALID,
@@ -251,7 +220,8 @@ export function resolveSubagentBinding(
251220
}
252221
return { model: own.modelAlias, thinking: own.thinkingLevel };
253222
}
254-
if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) {
223+
const choices = subagentPolicyModelChoices(policy) ?? {};
224+
if (Object.hasOwn(choices, PRIMARY_SUBAGENT_MODEL_CHOICE)) {
255225
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, {
256226
details: {
257227
section: SECONDARY_MODEL_SECTION,
@@ -260,21 +230,16 @@ export function resolveSubagentBinding(
260230
},
261231
});
262232
}
263-
const choice = requested ?? pool.defaultModel;
264-
if (choice === undefined) {
265-
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, {
266-
details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' },
267-
});
268-
}
269-
if (!Object.hasOwn(pool.models, choice)) {
270-
const available = [...Object.keys(pool.models), PRIMARY_SUBAGENT_MODEL_CHOICE];
233+
const choice = requested ?? policy.defaultModel;
234+
if (!Object.hasOwn(choices, choice)) {
235+
const available = [...Object.keys(choices), PRIMARY_SUBAGENT_MODEL_CHOICE];
271236
throw new Error2(
272237
ErrorCodes.CONFIG_INVALID,
273238
`Invalid model "${choice}". Available models: ${available.join(', ')}.`,
274239
{ details: { model: choice, availableModels: available } },
275240
);
276241
}
277-
return { model: choice, thinking: section?.defaultEffort };
242+
return { model: choice, thinking: policy.defaultEffort };
278243
}
279244

280245
export function resolveSubagentThinking(

0 commit comments

Comments
 (0)