Skip to content

Commit ecc1018

Browse files
committed
feat: remember the last chosen model and prefer newer versions in ranking
Removing every provider and re-adding one made new sessions start on zai/glm-5.2 instead of the zai/glm-5.3-flash that was in use. Two causes. Nothing recorded which model the user had actually chosen. default_model was the only memory of it, and the provider-removal cascade clears that pointer when the removed provider owned it; the value was then re-derived from a ranking and the choice was lost for good. The ranking's final tie-break also preferred older versions. Candidates tied on capability and context window fell through to an ascending id comparison, so "5.2" sorted before "5.3" and the oldest sibling won. Add a last_used_model config key alongside default_model, owned by IModelService and persisted through the same bridge as the default pointer. Default resolution now prefers the configured default, then the last chosen model, then the highest-ranked resolvable candidate, then the empty state. It is written only on an explicit switch through the session profile route, never from profile binding or the automatic fallback, and it is never cascade-cleared: a pointer at a missing model is inert because every read is readiness-gated, which is what lets a choice survive a provider round-trip. Order equally capable candidates by provider ascending, then model part descending with numeric collation, then full id for a total order. Splitting on the first slash only keeps compound model ids intact. Also fixes a defect this exposed: the models-section arm of onConfigSectionChanged re-hydrated the registry without the last-used value, resetting it and cascading the clear into config.toml.
1 parent 0e78e61 commit ecc1018

12 files changed

Lines changed: 363 additions & 5 deletions

File tree

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+
Your last chosen model is now remembered and restored when your configured default becomes unavailable, and equally capable models now prefer newer versions.

‎packages/agent-core-v2/docs/config-manifest.toml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# commented "# field: type" lines describe the remaining schema fields.
99
# Values resolve as: default -> config.toml -> env overlay -> memory.
1010

11-
# Index (28 sections · 2 overlay(s))
11+
# Index (29 sections · 2 overlay(s))
1212
# advisor src/session/advisor/configSection.ts
1313
# background src/agent/task/configSection.ts
1414
# builtinProductSkills src/features/skill/catalog/configSection.ts
@@ -22,6 +22,7 @@
2222
# hooks src/features/externalHooks/configSection.ts
2323
# identity src/app/agentIdentity/configSection.ts
2424
# image src/agent/media/configSection.ts
25+
# lastUsedModel src/app/kosongConfig/configSection.ts
2526
# llm src/app/kosongConfig/configSection.ts
2627
# loopControl src/agent/loop/configSection.ts
2728
# mcp src/app/mcpConfig/configSection.ts
@@ -203,6 +204,14 @@ extra_skill_dirs = []
203204
# max_edge_px: integer
204205
# read_byte_budget: integer
205206

207+
# ##########################################################################
208+
# lastUsedModel (config.toml: last_used_model)
209+
# owner: src/app/kosongConfig/configSection.ts
210+
# scope: core
211+
# ##########################################################################
212+
213+
# last_used_model: string
214+
206215
# ##########################################################################
207216
# llm
208217
# owner: src/app/kosongConfig/configSection.ts

‎packages/agent-core-v2/src/app/kosongConfig/configSection.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ export const MODELS_SECTION = 'models';
144144

145145
export const DEFAULT_MODEL_SECTION = 'defaultModel';
146146

147+
export const LAST_USED_MODEL_SECTION = 'lastUsedModel';
148+
147149
const ModelBaseSchema = z.object({
148150
providerId: z.string().optional(),
149151

@@ -260,6 +262,8 @@ registerConfigSection(MODELS_SECTION, ModelsSectionSchema, {
260262
toToml: modelsToToml,
261263
});
262264

265+
registerConfigSection(LAST_USED_MODEL_SECTION, z.string().optional());
266+
263267
export const THINKING_SECTION = 'thinking';
264268

265269
export const ThinkingConfigSchema = z.object({

‎packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { IKosongConfigService } from './kosongConfig';
1414
import {
1515
DEFAULT_MODEL_SECTION,
1616
DEFAULT_PROVIDER_SECTION,
17+
LAST_USED_MODEL_SECTION,
1718
MODELS_SECTION,
1819
PROVIDERS_SECTION,
1920
} from './configSection';
@@ -51,6 +52,7 @@ export class KosongConfigService extends Disposable implements IKosongConfigServ
5152
this.models.loadAll(
5253
this.config.get<ModelsSection>(MODELS_SECTION) ?? {},
5354
this.config.get<string>(DEFAULT_MODEL_SECTION),
55+
this.config.get<string>(LAST_USED_MODEL_SECTION),
5456
);
5557
this._register(this.config.onDidSectionChange((e) => this.onConfigSectionChanged(e)));
5658
this._register(
@@ -83,6 +85,12 @@ export class KosongConfigService extends Disposable implements IKosongConfigServ
8385
e.waitUntil(this.enqueuePersistDefaultPointer(DEFAULT_MODEL_SECTION, e.id));
8486
}),
8587
);
88+
this._register(
89+
this.models.onDidChangeLastUsedModel((e) => {
90+
if (this.config.get<string>(LAST_USED_MODEL_SECTION) === e.id) return;
91+
e.waitUntil(this.enqueuePersistDefaultPointer(LAST_USED_MODEL_SECTION, e.id));
92+
}),
93+
);
8694
}
8795

8896
private onConfigSectionChanged(e: ConfigSectionChangedEvent): void {
@@ -97,6 +105,7 @@ export class KosongConfigService extends Disposable implements IKosongConfigServ
97105
this.models.loadAll(
98106
(e.value as ModelsSection | undefined) ?? {},
99107
this.models.getDefaultModel(),
108+
this.models.getLastUsedModel(),
100109
);
101110
break;
102111
case DEFAULT_PROVIDER_SECTION:
@@ -109,6 +118,11 @@ export class KosongConfigService extends Disposable implements IKosongConfigServ
109118
.setDefaultModel(e.value as string | undefined)
110119
.catch((error) => this.logPersistFailure(error));
111120
break;
121+
case LAST_USED_MODEL_SECTION:
122+
void this.models
123+
.setLastUsedModel(e.value as string | undefined)
124+
.catch((error) => this.logPersistFailure(error));
125+
break;
112126
}
113127
}
114128

@@ -142,6 +156,10 @@ export class KosongConfigService extends Disposable implements IKosongConfigServ
142156
void this.models
143157
.setDefaultModel(effective)
144158
.catch((error) => this.logPersistFailure(error));
159+
} else if (domain === LAST_USED_MODEL_SECTION) {
160+
void this.models
161+
.setLastUsedModel(effective)
162+
.catch((error) => this.logPersistFailure(error));
145163
}
146164
});
147165
}

‎packages/agent-core-v2/src/kosong/model/defaultModelPolicy.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,34 @@ export function rankDefaultModelCandidates(models: ModelsSection): string[] {
2121
.toSorted((a, b) => {
2222
if (a.declaresToolUse !== b.declaresToolUse) return a.declaresToolUse ? -1 : 1;
2323
if (a.context !== b.context) return b.context - a.context;
24+
const aParts = splitModelId(a.id);
25+
const bParts = splitModelId(b.id);
26+
if (aParts.providerId !== bParts.providerId) {
27+
return aParts.providerId.localeCompare(bParts.providerId);
28+
}
29+
const byModelPart = bParts.modelPart.localeCompare(aParts.modelPart, 'en', {
30+
numeric: true,
31+
});
32+
if (byModelPart !== 0) return byModelPart;
2433
return a.id.localeCompare(b.id);
2534
})
2635
.map((candidate) => candidate.id);
2736
}
2837

38+
function splitModelId(id: string): { readonly providerId: string; readonly modelPart: string } {
39+
const slash = id.indexOf('/');
40+
if (slash === -1) return { providerId: '', modelPart: id };
41+
return { providerId: id.slice(0, slash), modelPart: id.slice(slash + 1) };
42+
}
43+
2944
export function resolveDefaultModel(
3045
models: ModelsSection,
3146
current: string | undefined,
3247
isReady: (id: string) => boolean = () => true,
48+
lastUsed?: string,
3349
): string | undefined {
3450
if (current !== undefined && isReady(current)) return current;
51+
if (lastUsed !== undefined && isReady(lastUsed)) return lastUsed;
3552
return rankDefaultModelCandidates(models).find((candidate) => isReady(candidate));
3653
}
3754

‎packages/agent-core-v2/src/kosong/model/model.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,17 @@ export interface IModelService {
7171
readonly settled: Promise<void>;
7272
readonly onDidChangeModels: Event<ModelsChangedEvent & IWaitUntil>;
7373
readonly onDidChangeDefaultModel: Event<DefaultModelChangedEvent & IWaitUntil>;
74+
readonly onDidChangeLastUsedModel: Event<DefaultModelChangedEvent & IWaitUntil>;
7475
get(id: string): ModelRecord | undefined;
7576
list(): Readonly<Record<string, ModelRecord>>;
7677
getDefaultModel(): string | undefined;
78+
getLastUsedModel(): string | undefined;
7779
set(id: string, model: ModelRecord): Promise<void>;
7880
delete(id: string): Promise<void>;
79-
loadAll(models: ModelsSection, defaultModel: string | undefined): void;
81+
loadAll(models: ModelsSection, defaultModel: string | undefined, lastUsedModel?: string): void;
8082
replaceAll(models: ModelsSection): Promise<void>;
8183
setDefaultModel(id: string | undefined): Promise<void>;
84+
setLastUsedModel(id: string | undefined): Promise<void>;
8285
}
8386

8487
export const IModelService: ServiceIdentifier<IModelService> =

‎packages/agent-core-v2/src/kosong/model/modelService.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export class ModelService extends Disposable implements IModelService {
2525

2626
private models: Readonly<Record<string, ModelRecord>> = {};
2727
private defaultModel: string | undefined;
28+
private lastUsedModel: string | undefined;
2829
private settling: Promise<void> = Promise.resolve();
2930
private hydrated = false;
3031
private resolveReady!: () => void;
@@ -49,6 +50,11 @@ export class ModelService extends Disposable implements IModelService {
4950
);
5051
readonly onDidChangeDefaultModel: Event<DefaultModelChangedEvent & IWaitUntil> =
5152
this._onDidChangeDefaultModel.event;
53+
private readonly _onDidChangeLastUsedModel = this._register(
54+
new AsyncEmitter<DefaultModelChangedEvent & IWaitUntil>(),
55+
);
56+
readonly onDidChangeLastUsedModel: Event<DefaultModelChangedEvent & IWaitUntil> =
57+
this._onDidChangeLastUsedModel.event;
5258

5359
get settled(): Promise<void> {
5460
return this.settling;
@@ -66,9 +72,14 @@ export class ModelService extends Disposable implements IModelService {
6672
return this.defaultModel;
6773
}
6874

69-
loadAll(models: ModelsSection, defaultModel: string | undefined): void {
75+
getLastUsedModel(): string | undefined {
76+
return this.lastUsedModel;
77+
}
78+
79+
loadAll(models: ModelsSection, defaultModel: string | undefined, lastUsedModel?: string): void {
7080
void this.applyRecords(models);
7181
void this.applyDefaultModel(defaultModel);
82+
void this.applyLastUsedModel(lastUsedModel);
7283
this.settling = this.settleDefaultModel();
7384
if (!this.hydrated) {
7485
this.hydrated = true;
@@ -102,11 +113,22 @@ export class ModelService extends Disposable implements IModelService {
102113
await this.applyDefaultModel(id);
103114
}
104115

116+
async setLastUsedModel(id: string | undefined): Promise<void> {
117+
await this.ready;
118+
await this.applyLastUsedModel(id);
119+
}
120+
105121
private settleDefaultModel(): Promise<void> {
106122
const current = this.defaultModel;
123+
const lastUsed = this.lastUsedModel;
107124
const resolve = (id: string) =>
108125
resolveModelForReady(id, this.models, this.providers.list(), this.providers.getDefaultProvider());
109-
const next = resolveDefaultModel(this.models, current, (id) => resolve(id).resolved);
126+
const next = resolveDefaultModel(
127+
this.models,
128+
current,
129+
(id) => resolve(id).resolved,
130+
lastUsed,
131+
);
110132
const currentResolution = current === undefined ? undefined : resolve(current);
111133
if (
112134
currentResolution !== undefined &&
@@ -145,6 +167,12 @@ export class ModelService extends Disposable implements IModelService {
145167
this.defaultModel = id;
146168
await this._onDidChangeDefaultModel.fireAsync({ id }, NO_ABORT);
147169
}
170+
171+
private async applyLastUsedModel(id: string | undefined): Promise<void> {
172+
if (this.lastUsedModel === id) return;
173+
this.lastUsedModel = id;
174+
await this._onDidChangeLastUsedModel.fireAsync({ id }, NO_ABORT);
175+
}
148176
}
149177

150178
registerScopedService(LifecycleScope.App, IModelService, ModelService, ScopeActivation.OnScopeCreated, 'model');

‎packages/agent-core-v2/test/app/kosongConfig/kosongConfigService.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ILogService, type LogPayload } from '#/_base/log/log';
44
import {
55
DEFAULT_MODEL_SECTION,
66
DEFAULT_PROVIDER_SECTION,
7+
LAST_USED_MODEL_SECTION,
78
MODELS_SECTION,
89
PROVIDERS_SECTION,
910
} from '#/app/kosongConfig/configSection';
@@ -100,6 +101,18 @@ describe('KosongConfigService startup hydration', () => {
100101
expect(models.list()).toEqual({});
101102
expect(models.getDefaultModel()).toBeUndefined();
102103
});
104+
105+
it('hydrates the last-used model pointer from config', async () => {
106+
const { models } = await createBridge({ ...seededSections, lastUsedModel: 'k1' });
107+
108+
expect(models.getLastUsedModel()).toBe('k1');
109+
});
110+
111+
it('leaves the last-used pointer unset when config does not carry one', async () => {
112+
const { models } = await createBridge(seededSections);
113+
114+
expect(models.getLastUsedModel()).toBeUndefined();
115+
});
103116
});
104117

105118
describe('KosongConfigService kosong → config persistence', () => {
@@ -158,6 +171,23 @@ describe('KosongConfigService kosong → config persistence', () => {
158171
bridge.dispose();
159172
}
160173
});
174+
175+
it('persists the last-used model pointer', async () => {
176+
const { config, models, bridge } = await createBridge(seededSections);
177+
try {
178+
await models.setLastUsedModel('k1');
179+
await flush();
180+
181+
expect(config.get<string>(LAST_USED_MODEL_SECTION)).toBe('k1');
182+
183+
await models.setLastUsedModel(undefined);
184+
await flush();
185+
186+
expect(config.get<string>(LAST_USED_MODEL_SECTION)).toBeUndefined();
187+
} finally {
188+
bridge.dispose();
189+
}
190+
});
161191
});
162192

163193
describe('KosongConfigService awaited-mutation semantics', () => {
@@ -261,6 +291,10 @@ describe('KosongConfigService config → kosong sync', () => {
261291
await config.replace(DEFAULT_PROVIDER_SECTION, 'openai');
262292
await flush();
263293
expect(providers.getDefaultProvider()).toBe('openai');
294+
295+
await config.replace(LAST_USED_MODEL_SECTION, 'k2');
296+
await flush();
297+
expect(models.getLastUsedModel()).toBe('k2');
264298
} finally {
265299
bridge.dispose();
266300
}
@@ -311,6 +345,35 @@ describe('KosongConfigService loop termination', () => {
311345
});
312346
});
313347

348+
describe('KosongConfigService last-used model survival', () => {
349+
it('keeps last_used_model in config when the models section is wiped and the default pointer clears', async () => {
350+
const { config, models, bridge } = await createBridge({
351+
...seededSections,
352+
lastUsedModel: 'k1',
353+
});
354+
try {
355+
expect(models.getDefaultModel()).toBe('k1');
356+
357+
await config.replace(MODELS_SECTION, {});
358+
await flush();
359+
360+
expect(models.list()).toEqual({});
361+
expect(models.getDefaultModel()).toBeUndefined();
362+
expect(config.get<string>(DEFAULT_MODEL_SECTION)).toBeUndefined();
363+
expect(config.get<string>(LAST_USED_MODEL_SECTION)).toBe('k1');
364+
expect(models.getLastUsedModel()).toBe('k1');
365+
366+
await config.replace(MODELS_SECTION, { k1: K1_MODEL });
367+
await flush();
368+
369+
expect(models.getDefaultModel()).toBe('k1');
370+
expect(config.get<string>(DEFAULT_MODEL_SECTION)).toBe('k1');
371+
} finally {
372+
bridge.dispose();
373+
}
374+
});
375+
});
376+
314377
describe('KosongConfigService default-provider deletion', () => {
315378
it('clears the pointer when the default provider is deleted and persists the cleared pointer', async () => {
316379
const { config, providers, bridge } = await createBridge({

0 commit comments

Comments
 (0)