Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/restore-subagent-model-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Fix the subagent model settings in web Settings → Agent, which were hidden because the server no longer answered their requests.
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ export * from '#/session/subagent/spawn';
import '#/session/subagent/flag';
export * from '#/session/subagent/subagentModelsValidation';
import '#/session/subagent/subagentModelsValidationService';
export type { CanonicalSubagentModelPolicy } from '#/session/subagent/policy';
export * from '#/session/subagent/subagentModelPolicy';
import '#/session/subagent/subagentModelPolicyService';
export * from '#/agent/tools/agent/subagent-task';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export class SubagentModelPolicyService implements ISubagentModelPolicyService {
const currentVersion = subagentPolicyResourceVersion(this.persisted());
if (currentVersion !== expectedVersion) {
throw new Error2(
ErrorCodes.VALIDATION_FAILED,
ErrorCodes.CONFIG_VERSION_CONFLICT,
'The subagent model policy changed since it was read; reload and retry.',
{ details: { section: SECONDARY_MODEL_SECTION, expectedVersion, currentVersion } },
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IConfigService } from '#/app/config/config';
import { THINKING_SECTION } from '#/app/kosongConfig/configSection';
import { ErrorCodes, Error2, isError2 } from '#/errors';
import { UNKNOWN_CAPABILITY } from '#/llm-adapter/contract/capability';
import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog';
import { SECONDARY_MODEL_SECTION } from '#/session/subagent/policy';
import {
ISubagentModelPolicyService,
type SubagentModelPolicySnapshot,
} from '#/session/subagent/subagentModelPolicy';
import { SubagentModelPolicyService } from '#/session/subagent/subagentModelPolicyService';

import { StubConfigService } from '../../stubs';

function testModel(id: string, extra: Partial<Model> = {}): Model {
return {
id,
name: id,
aliases: [],
protocol: 'openai',
headers: {},
capabilities: UNKNOWN_CAPABILITY,
maxContextSize: 128_000,
alwaysThinking: false,
providerName: 'acme',
...extra,
};
}

describe('SubagentModelPolicyService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let config: StubConfigService;
let models: Map<string, Model>;

beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
models = new Map([
['acme/sol', testModel('acme/sol', { supportEfforts: ['low', 'high'], defaultEffort: 'high' })],
['acme/luna', testModel('acme/luna', { supportEfforts: ['low', 'high'] })],
]);
});
afterEach(() => disposables.dispose());

function setup(sections: Record<string, unknown>): ISubagentModelPolicyService {
config = new StubConfigService(sections);
ix.stub(IConfigService, config);
ix.stub(IModelCatalog, {
_serviceBrand: undefined,
get: (id: string) => {
const model = models.get(id);
if (model === undefined) {
throw new Error2(ErrorCodes.CONFIG_INVALID, `Model "${id}" is not configured in config.toml.`, {
details: { model: id },
});
}
return model;
},
});
ix.set(ISubagentModelPolicyService, new SyncDescriptor(SubagentModelPolicyService));
return ix.get(ISubagentModelPolicyService);
}

async function codeOf(fn: () => Promise<unknown> | unknown): Promise<string | undefined> {
try {
await fn();
return undefined;
} catch (error) {
return isError2(error) ? error.code : 'not-error2';
}
}

it('reads the persisted policy with a resource version and reports inherit for an absent section', () => {
const service = setup({});
const snapshot = service.get();
expect(snapshot.policy).toEqual({ mode: 'inherit' });
expect(snapshot.resourceVersion).toMatch(/^subagent-policy-v1:/);
});

it('set validates against the live catalog and persists the canonical section', async () => {
const service = setup({});
await expect(
service.set({ mode: 'default', defaultModel: 'acme/nope' }),
).rejects.toMatchObject({ code: ErrorCodes.CONFIG_INVALID });
expect(config.get(SECONDARY_MODEL_SECTION)).toBeUndefined();

const snapshot = await service.set({ mode: 'force', defaultModel: 'acme/sol', defaultEffort: 'low' });
expect(snapshot.policy).toEqual({ mode: 'force', defaultModel: 'acme/sol', defaultEffort: 'low' });
expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({
defaultModel: 'acme/sol',
force: true,
defaultEffort: 'low',
});

await service.set({ mode: 'default', defaultModel: 'acme/luna' });
expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({
defaultModel: 'acme/luna',
defaultEffort: undefined,
});
});

it('clear removes the section and expectedVersion guards both set and clear', async () => {
const service = setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'acme/sol' } });
const before = service.get().resourceVersion;
expect(await codeOf(() => service.set({ mode: 'inherit' }, 'subagent-policy-v1:stale'))).toBe(
ErrorCodes.CONFIG_VERSION_CONFLICT,
);
expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({ defaultModel: 'acme/sol' });
const cleared = await service.clear(before);
expect(cleared.policy).toEqual({ mode: 'inherit' });
expect(config.get(SECONDARY_MODEL_SECTION)).toBeUndefined();
expect(cleared.resourceVersion).not.toBe(before);
expect(await codeOf(() => service.clear(before))).toBe(ErrorCodes.CONFIG_VERSION_CONFLICT);
});

it('serializes concurrent commits so a stale expectedVersion cannot slip past the version check', async () => {
const service = setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'acme/sol' } });
const replace = config.replace.bind(config);
config.replace = async (domain, value) => {
await new Promise((resolve) => setTimeout(resolve, 0));
await replace(domain, value);
};
const version = service.get().resourceVersion;
const [first, second] = await Promise.allSettled([
service.set({ mode: 'default', defaultModel: 'acme/luna' }, version),
service.set({ mode: 'force', defaultModel: 'acme/sol' }, version),
]);
expect(first.status).toBe('fulfilled');
expect(second.status).toBe('rejected');
expect((second as PromiseRejectedResult).reason).toMatchObject({
code: ErrorCodes.CONFIG_VERSION_CONFLICT,
});
expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({ defaultModel: 'acme/luna', defaultEffort: undefined });
const recovered = await service.set(
{ mode: 'force', defaultModel: 'acme/sol' },
(first as PromiseFulfilledResult<SubagentModelPolicySnapshot>).value.resourceVersion,
);
expect(recovered.policy).toEqual({ mode: 'force', defaultModel: 'acme/sol' });
expect(config.get(SECONDARY_MODEL_SECTION)).toEqual({ defaultModel: 'acme/sol', force: true, defaultEffort: undefined });
});

it('prepareLegacyMutation validates against the supplied prospective context, not the live catalog', () => {
const service = setup({});
const prospective = {
resolveModel: (alias: string) => (alias === 'acme/future' ? { id: alias } : undefined),
};
const prepared = service.prepareLegacyMutation(
{ defaultModel: 'acme/future', models: { 'acme/future': 'soon' } },
prospective,
);
expect(prepared.policy).toEqual({
mode: 'pool',
defaultModel: 'acme/future',
models: { 'acme/future': 'soon' },
defaultEffort: undefined,
});
expect(prepared.section).toEqual({
defaultModel: 'acme/future',
models: { 'acme/future': 'soon' },
defaultEffort: undefined,
});
expect(config.get(SECONDARY_MODEL_SECTION)).toBeUndefined();
expect(() => service.prepareLegacyMutation({ defaultModel: 'acme/sol' }, prospective)).toThrow();
expect(() => service.prepareLegacyMutation({ defaultModel: 'acme/sol' })).not.toThrow();
expect(service.prepareLegacyMutation(null)).toEqual({ policy: { mode: 'inherit' }, section: undefined });
expect(() => service.prepareLegacyMutation({ defaultModel: 42 })).toThrow();
});

it('resolveRevision changes only with ambient routing inputs', async () => {
const service = setup({ [SECONDARY_MODEL_SECTION]: { defaultModel: 'acme/luna' } });
const caller = { modelAlias: 'acme/sol', thinkingLevel: 'high' };
const a = service.resolveRevision(caller);
expect(a).toMatch(/^route-env:v1:/);
expect(service.resolveRevision({ ...caller })).toBe(a);
expect(service.resolveRevision({ modelAlias: 'acme/luna', thinkingLevel: 'high' })).not.toBe(a);
await config.replace(THINKING_SECTION, { enabled: false });
expect(service.resolveRevision(caller)).not.toBe(a);
});
});
41 changes: 41 additions & 0 deletions packages/agent-gateway/src/protocol/rest-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,47 @@ export const configResponseSchema = z
.passthrough();
export type ConfigResponse = z.infer<typeof configResponseSchema>;

const policyModelAlias = z.string().min(1);
const policyEffort = z.string().min(1).optional();

export const subagentModelPolicyRequestSchema = z.discriminatedUnion('mode', [
z.object({ mode: z.literal('inherit') }).strict(),
z.object({ mode: z.literal('default'), default_model: policyModelAlias, default_effort: policyEffort }).strict(),
z
.object({
mode: z.literal('pool'),
default_model: policyModelAlias,
models: z.record(z.string(), z.string()),
default_effort: policyEffort,
})
.strict(),
z.object({ mode: z.literal('force'), default_model: policyModelAlias, default_effort: policyEffort }).strict(),
]);
export type SubagentModelPolicyRequest = z.infer<typeof subagentModelPolicyRequestSchema>;

export const subagentModelPolicyWireSchema = z.object({
mode: z.enum(['inherit', 'default', 'pool', 'force']),
default_model: z.string().optional(),
models: z.record(z.string(), z.string()).optional(),
default_effort: z.string().optional(),
});
export type SubagentModelPolicyWire = z.infer<typeof subagentModelPolicyWireSchema>;

export const subagentModelPolicyResponseSchema = z.object({
policy: subagentModelPolicyWireSchema,
resource_version: z.string().min(1),
effective: z.object({
configured_policy: subagentModelPolicyWireSchema,
effective_policy: subagentModelPolicyWireSchema,
policy_source: z.enum(['config', 'default']),
feature: z.object({
enabled: z.boolean(),
source: z.enum(['master-env', 'env', 'config', 'default']),
}),
}),
});
export type SubagentModelPolicyResponse = z.infer<typeof subagentModelPolicyResponseSchema>;

export const patchConfigRequestSchema = z.object({
providers: z.record(z.string(), z.unknown()).optional(),
default_provider: z.string().optional(),
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-gateway/src/routes/registerApiV1Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { registerAuthRoute } from './auth';
import { registerCodexLoginRoutes } from './codex';
import { registerCapabilitiesRoutes } from './capabilities';
import { registerConfigRoutes } from './config';
import { registerSubagentModelPolicyRoutes } from './subagentModelPolicy';
import { registerConnectionsRoutes } from './connections';
import { registerFileHistoryRoutes } from './fileHistory';
import { registerFilesRoutes } from './files';
Expand Down Expand Up @@ -114,6 +115,10 @@ export async function registerApiV1Routes(
registerAuthRoute(apiV1 as unknown as Parameters<typeof registerAuthRoute>[0], core);
registerCodexLoginRoutes(apiV1 as unknown as Parameters<typeof registerCodexLoginRoutes>[0], core);
registerConfigRoutes(apiV1 as unknown as Parameters<typeof registerConfigRoutes>[0], core);
registerSubagentModelPolicyRoutes(
apiV1 as unknown as Parameters<typeof registerSubagentModelPolicyRoutes>[0],
core,
);
registerModelCatalogRoutes(
apiV1 as unknown as Parameters<typeof registerModelCatalogRoutes>[0],
core,
Expand Down
Loading
Loading