Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ebcb414
fix(config): replace subagent model settings atomically instead of me…
elkaix Aug 28, 2026
805dd83
fix(config): apply replace mode to custom TOML serializers and read t…
elkaix Aug 28, 2026
4cd44e9
feat(meta): expose effective experimental flag state and show it in Lab
elkaix Aug 28, 2026
f785d0d
fix(flags): report externallyControlled and overridden from the v1 re…
elkaix Aug 28, 2026
ce882e9
test(tui): carry the two flag-state fields in the experiments fixtures
elkaix Aug 28, 2026
14215dd
test(sdk): expect the two flag-state fields from the harness
elkaix Aug 28, 2026
9cf318e
fix(web): refresh flag state after a config save and use tokens for t…
elkaix Aug 28, 2026
e731b91
feat(config): canonical subagent model policy with a dedicated endpoint
elkaix Aug 28, 2026
af06696
test(config): record the subagent model policy routes and keep the ma…
elkaix Aug 28, 2026
f9599fb
test(gateway): prove the policy DELETE removed the section before the…
elkaix Aug 28, 2026
897b289
fix(meta): guard /meta ordering in Settings and drop test type assert…
elkaix Aug 28, 2026
9ee3308
Merge branch 'feat/effective-flag-state' into feat/subagent-model-policy
elkaix Aug 28, 2026
4bb8328
fix(subagent): reconcile refreshed catalogs with the policy and harde…
elkaix Aug 28, 2026
f559898
fix(oauth): clamp a dangling legacy model key in secondary_model too
elkaix Aug 28, 2026
3d220d7
test(agent-core-v2): prove a later commit succeeds after a stale-vers…
elkaix Aug 28, 2026
d501020
Merge remote-tracking branch 'origin/main' into feat/subagent-model-p…
elkaix Aug 28, 2026
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
5 changes: 5 additions & 0 deletions .changeset/subagent-model-policy-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Add a subagent model policy setting with inherit, default, pool, and force modes that rejects models that are not configured.
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/app/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export interface IConfigService {
sections: Readonly<Record<string, unknown>>,
target?: ConfigTarget,
): Promise<void>;
previewReplaceSections(sections: Readonly<Record<string, unknown>>): ResolvedConfig;
reload(): Promise<void>;
diagnostics(): readonly ConfigDiagnostic[];
}
Expand Down
21 changes: 20 additions & 1 deletion packages/agent-core-v2/src/app/config/configService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,24 @@ export class ConfigService extends Disposable implements IConfigService {
});
}

previewReplaceSections(sections: Readonly<Record<string, unknown>>): ResolvedConfig {
const stagedRaw: ResolvedConfig = { ...this.raw };
const stagedRawSnake = cloneRecord(this.rawSnake);
for (const domain of Object.keys(sections)) {
const value = sections[domain] === null ? undefined : sections[domain];
const stripped = this.stripEnv(domain, value, stagedRaw, stagedRawSnake);
if (stripped === undefined) {
delete stagedRaw[domain];
} else {
stagedRaw[domain] = this.registry.validate(domain, stripped);
}
}
const next: ResolvedConfig = { ...this.buildValidated(stagedRaw, false) };
this.applySectionEnvBindings(next, false);
this.applyEnvOverlay(next, false);
return { ...next, ...this.memory };
}

private stripEnv(
domain: string,
value: unknown,
Expand Down Expand Up @@ -599,12 +617,13 @@ export class ConfigService extends Disposable implements IConfigService {
}
}

private buildValidated(raw: ResolvedConfig): ResolvedConfig {
private buildValidated(raw: ResolvedConfig, report = true): ResolvedConfig {
const validated: ResolvedConfig = {};
for (const [domain, value] of Object.entries(raw)) {
try {
validated[domain] = this.registry.validate(domain, value);
} catch (error) {
if (!report) continue;
this.pushDiagnostic({
domain,
severity: 'warning',
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/app/config/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const ConfigErrors = {
codes: {
CONFIG_INVALID: CONFIG_INVALID_ERROR_CODE,
CONFIG_PERSIST_BLOCKED: 'config.persist_blocked',
CONFIG_VERSION_CONFLICT: 'config.version_conflict',
},
} as const satisfies ErrorDomain;

Expand Down
12 changes: 8 additions & 4 deletions packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ import {
PROVIDERS_SECTION,
THINKING_SECTION,
} from './configSection';
import {
SECONDARY_MODEL_SECTION,
} from '#/session/subagent/configSection';
import { prospectiveModelView, SECONDARY_MODEL_SECTION } from '#/session/subagent/policy';
import { ISubagentModelPolicyService } from '#/session/subagent/subagentModelPolicy';
import {
IProviderDiscoveryService,
ModelCatalogChanged,
Expand All @@ -54,6 +53,7 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
@IConfigService private readonly config: IConfigService,
@IEventService private readonly events: IEventService,
@IAgentIdentity private readonly identity: IAgentIdentity,
@ISubagentModelPolicyService private readonly subagentPolicy: ISubagentModelPolicyService,
) {}

refreshProviderModels(
Expand Down Expand Up @@ -216,7 +216,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking;
}
if ('secondaryModel' in patch) {
sections[SECONDARY_MODEL_SECTION] = patch.secondaryModel;
const preview = this.config.previewReplaceSections(sections);
sections[SECONDARY_MODEL_SECTION] = this.subagentPolicy.prepareLegacyMutation(
patch.secondaryModel,
prospectiveModelView(preview[PROVIDERS_SECTION], preview[MODELS_SECTION]),
).section;
}
await this.config.replaceSections(sections);
return {
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,9 @@ export * from '#/session/subagent/spawn';
import '#/session/subagent/flag';
export * from '#/session/subagent/subagentModelsValidation';
import '#/session/subagent/subagentModelsValidationService';
export * from '#/session/subagent/policy';
export * from '#/session/subagent/subagentModelPolicy';
import '#/session/subagent/subagentModelPolicyService';
export * from '#/agent/tools/agent/subagent-task';
export { AGENT_RUN_PROMPT_ORIGIN } from '#/session/subagent/runAgentTurn';
export * from '#/session/subagent/mirrorAgentRun';
Expand Down
171 changes: 68 additions & 103 deletions packages/agent-core-v2/src/session/subagent/configSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,43 @@ import {
} from '#/kosong/model/thinking';

import { SECONDARY_MODEL_FLAG_ID } from './flag';
import {
type CanonicalSubagentModelPolicy,
INHERIT_SUBAGENT_MODEL_POLICY,
type LegacySecondaryModelConfig,
LegacySecondaryModelConfigSchema,
normalizeLegacySecondaryModel,
normalizeLegacySecondaryModelOrInherit,
PRIMARY_SUBAGENT_MODEL_CHOICE,
SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE,
SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE,
SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE,
SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE,
SECONDARY_MODEL_SECTION,
subagentPolicyModelChoices,
validateSubagentModelPolicy,
} from './policy';

export {
PRIMARY_SUBAGENT_MODEL_CHOICE,
SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE,
SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE,
SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE,
SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE,
SECONDARY_MODEL_SECTION,
};

export const SUBAGENT_SECTION = 'subagent';
export const SECONDARY_MODEL_SECTION = 'secondaryModel';

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

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

export const SecondaryModelConfigSchema = z.object({
defaultModel: z.string().min(1).optional(),
models: z.record(z.string(), z.string()).optional(),
force: z.boolean().optional(),
model: z.string().min(1).optional(),
maxContextSize: z.number().int().min(1).optional(),
maxInputSize: z.number().int().min(1).optional(),
maxOutputSize: z.number().int().min(1).optional(),
capabilities: z.array(z.string()).optional(),
displayName: z.string().optional(),
reasoningKey: z.string().optional(),
adaptiveThinking: z.boolean().optional(),
supportEfforts: z.array(z.string()).optional(),
defaultEffort: z.string().optional(),
offEffort: z.string().optional(),
});
export const SecondaryModelConfigSchema = LegacySecondaryModelConfigSchema;

export type SecondaryModelConfig = z.infer<typeof SecondaryModelConfigSchema>;
export type SecondaryModelConfig = LegacySecondaryModelConfig;

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

Expand All @@ -71,7 +80,7 @@ registerConfigSection(SUBAGENT_SECTION, SubagentConfigSchema, {
stripEnv: stripSubagentEnv,
});

registerConfigSection(SECONDARY_MODEL_SECTION, SecondaryModelConfigSchema);
registerConfigSection('secondaryModel', SecondaryModelConfigSchema);

export function resolveSubagentTimeoutMs(config: IConfigService): number {
return (
Expand All @@ -80,35 +89,32 @@ export function resolveSubagentTimeoutMs(config: IConfigService): number {
);
}

export const PRIMARY_SUBAGENT_MODEL_CHOICE = 'primary';

export interface SubagentModelPool {
readonly defaultModel?: string;
readonly models: Record<string, string>;
}

export function resolveSubagentModelPool(config: IConfigService): SubagentModelPool | undefined {
const section = config.get<SecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION);
if (section?.models !== undefined) {
return { defaultModel: section.defaultModel, models: section.models };
}
if (section?.defaultModel !== undefined) {
return { defaultModel: section.defaultModel, models: { [section.defaultModel]: '' } };
}
if (section?.model !== undefined) {
return { defaultModel: section.model, models: { [section.model]: '' } };
}
return undefined;
function configuredPolicy(config: IConfigService): CanonicalSubagentModelPolicy {
return normalizeLegacySecondaryModel(
config.get<LegacySecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION),
);
}

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

export const SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE =
'[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';
export function resolveSubagentModelPool(config: IConfigService): SubagentModelPool | undefined {
const policy = configuredPolicyOrInherit(config);
const models = subagentPolicyModelChoices(policy);
if (policy.mode === 'inherit' || models === undefined) return undefined;
return { defaultModel: policy.defaultModel, models: { ...models } };
}

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

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

export const SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE =
'[secondary_model].default_model is required when [secondary_model.models] is configured';

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.`;
function catalogValidationContext(modelCatalog: IModelCatalog) {
return {
resolveModel(alias: string) {
const model = modelCatalog.get(alias);
return { id: model.id, defaultEffort: model.defaultEffort, supportEfforts: model.supportEfforts };
},
};
}

export function assertValidSubagentModelPool(
pool: SubagentModelPool,
Expand All @@ -135,30 +145,15 @@ export function assertValidSubagentModelPool(
},
});
}
const aliases = Object.keys(pool.models);
if (pool.defaultModel === undefined) {
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, {
details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' },
});
}
if (!Object.hasOwn(pool.models, pool.defaultModel)) {
throw new Error2(
ErrorCodes.CONFIG_INVALID,
`[secondary_model].default_model "${pool.defaultModel}" is not a [secondary_model.models] key. Available models: ${aliases.join(', ')}.`,
{ details: { model: pool.defaultModel, availableModels: aliases } },
);
}
for (const alias of aliases) {
try {
modelCatalog.get(alias);
} catch (error) {
throw new Error2(
ErrorCodes.CONFIG_INVALID,
`[secondary_model.models] entry "${alias}" could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error, details: { model: alias } },
);
}
}
validateSubagentModelPolicy(
{ mode: 'pool', defaultModel: pool.defaultModel, models: { ...pool.models } },
catalogValidationContext(modelCatalog),
);
}

export function assertValidSubagentModelConfig(
Expand All @@ -167,21 +162,7 @@ export function assertValidSubagentModelConfig(
modelCatalog: IModelCatalog,
): void {
if (!flags.enabled(SECONDARY_MODEL_FLAG_ID)) return;
const section = config.get<SecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION);
if (section?.force === true) {
if (section.models !== undefined) {
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, {
details: { section: SECONDARY_MODEL_SECTION, field: 'force' },
});
}
if (section.defaultModel === undefined && section.model === undefined) {
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, {
details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' },
});
}
}
const pool = resolveSubagentModelPool(config);
if (pool !== undefined) assertValidSubagentModelPool(pool, modelCatalog);
validateSubagentModelPolicy(configuredPolicy(config), catalogValidationContext(modelCatalog));
}

export function cascadeSubagentModelPool(
Expand Down Expand Up @@ -215,33 +196,21 @@ export function resolveSubagentBinding(
requested?: string,
): { model: string; thinking?: string } {
const enabled = flags.enabled(SECONDARY_MODEL_FLAG_ID);
const section = config.get<SecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION);
if (enabled && section?.force === true) {
if (section.models !== undefined) {
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_EXCLUDES_MODELS_MESSAGE, {
details: { section: SECONDARY_MODEL_SECTION, field: 'force' },
});
}
const forcedModel = section.defaultModel ?? section.model;
if (forcedModel === undefined) {
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_FORCE_REQUIRES_DEFAULT_MESSAGE, {
details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' },
});
}
const policy = enabled ? configuredPolicy(config) : INHERIT_SUBAGENT_MODEL_POLICY;
if (policy.mode === 'force') {
if (requested !== undefined) {
throw new Error2(
ErrorCodes.CONFIG_INVALID,
`Invalid model "${requested}": [secondary_model].force is set, so every subagent binds "${forcedModel}" (omit the model parameter).`,
`Invalid model "${requested}": [secondary_model].force is set, so every subagent binds "${policy.defaultModel}" (omit the model parameter).`,
{ details: { model: requested } },
);
}
return { model: forcedModel, thinking: section.defaultEffort };
return { model: policy.defaultModel, thinking: policy.defaultEffort };
}
if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) {
return { model: own.modelAlias, thinking: own.thinkingLevel };
}
const pool = enabled ? resolveSubagentModelPool(config) : undefined;
if (pool === undefined) {
if (policy.mode === 'inherit') {
if (requested !== undefined) {
throw new Error2(
ErrorCodes.CONFIG_INVALID,
Expand All @@ -251,7 +220,8 @@ export function resolveSubagentBinding(
}
return { model: own.modelAlias, thinking: own.thinkingLevel };
}
if (Object.hasOwn(pool.models, PRIMARY_SUBAGENT_MODEL_CHOICE)) {
const choices = subagentPolicyModelChoices(policy) ?? {};
if (Object.hasOwn(choices, PRIMARY_SUBAGENT_MODEL_CHOICE)) {
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_PRIMARY_MODEL_RESERVED_MESSAGE, {
details: {
section: SECONDARY_MODEL_SECTION,
Expand All @@ -260,21 +230,16 @@ export function resolveSubagentBinding(
},
});
}
const choice = requested ?? pool.defaultModel;
if (choice === undefined) {
throw new Error2(ErrorCodes.CONFIG_INVALID, SECONDARY_MODEL_DEFAULT_MODEL_REQUIRED_MESSAGE, {
details: { section: SECONDARY_MODEL_SECTION, field: 'defaultModel' },
});
}
if (!Object.hasOwn(pool.models, choice)) {
const available = [...Object.keys(pool.models), PRIMARY_SUBAGENT_MODEL_CHOICE];
const choice = requested ?? policy.defaultModel;
if (!Object.hasOwn(choices, choice)) {
const available = [...Object.keys(choices), PRIMARY_SUBAGENT_MODEL_CHOICE];
throw new Error2(
ErrorCodes.CONFIG_INVALID,
`Invalid model "${choice}". Available models: ${available.join(', ')}.`,
{ details: { model: choice, availableModels: available } },
);
}
return { model: choice, thinking: section?.defaultEffort };
return { model: choice, thinking: policy.defaultEffort };
}

export function resolveSubagentThinking(
Expand Down
Loading
Loading