Skip to content

Commit d40c542

Browse files
committed
fix(agent-core): keep profile-routed subagent model on resume and retry
Resume and retry copied the parent's modelAlias onto the child, so a profile that routes its subagents to another model reverted to the orchestrator's model on the second turn. Both paths now re-resolve through the spawn precedence (explicit option -> profile -> parent), falling back to the parent when the child's provider cannot resolve the requested alias.
1 parent cdb3572 commit d40c542

3 files changed

Lines changed: 107 additions & 12 deletions

File tree

‎packages/agent-core/src/agent/config/index.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,11 @@ export class ConfigState {
169169
: createProvider(providerConfig).supportsFastMode === true;
170170
}
171171

172+
/** Whether this agent's provider can resolve `modelAlias` at all. */
173+
canResolveModel(modelAlias: string | undefined): boolean {
174+
return this.tryResolveProviderFor(modelAlias) !== undefined;
175+
}
176+
172177
get profileName(): string | undefined {
173178
return this._profileName;
174179
}
@@ -195,8 +200,13 @@ export class ConfigState {
195200
}
196201

197202
private tryResolvedProviderConfig(): ResolvedRuntimeProvider | undefined {
203+
return this.tryResolveProviderFor(this._modelAlias);
204+
}
205+
206+
private tryResolveProviderFor(modelAlias: string | undefined): ResolvedRuntimeProvider | undefined {
207+
if (modelAlias === undefined) return undefined;
198208
try {
199-
return this.resolvedProviderConfig;
209+
return this.agent.modelProvider?.resolveProviderConfig(modelAlias);
200210
} catch {
201211
return undefined;
202212
}

‎packages/agent-core/src/session/subagent-host.ts‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,9 @@ export class SessionSubagentHost {
238238
const completion = this.runWithActiveChild(agentId, options, async (runOptions) => {
239239
this.emitSubagentSpawned(parent, agentId, profileName, runOptions);
240240
try {
241-
child.config.update({
242-
modelAlias: parent.config.modelAlias,
243-
fastMode: parent.config.fastMode,
244-
});
241+
child.config.update(
242+
this.childModelConfig(parent, child, this.tryResolveProfile(parent, profileName), runOptions),
243+
);
245244
return await this.runPromptTurn(parent, agentId, child, profileName, runOptions);
246245
} catch (error) {
247246
this.emitSubagentFailed(parent, agentId, runOptions, error);
@@ -257,10 +256,9 @@ export class SessionSubagentHost {
257256
const completion = this.runWithActiveChild(agentId, options, async (runOptions) => {
258257
try {
259258
runOptions.signal.throwIfAborted();
260-
child.config.update({
261-
modelAlias: parent.config.modelAlias,
262-
fastMode: parent.config.fastMode,
263-
});
259+
child.config.update(
260+
this.childModelConfig(parent, child, this.tryResolveProfile(parent, profileName), runOptions),
261+
);
264262
this.emitSubagentStarted(parent, agentId, runOptions.parentToolCallId);
265263
const turnId = child.turn.retry('agent-host');
266264
if (turnId === null) {
@@ -374,6 +372,43 @@ export class SessionSubagentHost {
374372
return metadata.dynamicWorkflowItem;
375373
}
376374

375+
/**
376+
* Model selection for a child: explicit option → profile → parent. Resume and
377+
* retry re-resolve through the same precedence, so a profile that routes its
378+
* subagents to another model (and provider) is not silently replaced by the
379+
* parent's model on the second turn.
380+
*
381+
* An alias the provider cannot resolve (e.g. a typo, or a session built on
382+
* SingleModelProvider) falls back to the parent's model instead of failing at
383+
* generate time. fastMode stays a straight inherit: it is a preference the
384+
* provider layer already drops when the active model cannot serve it.
385+
*/
386+
private childModelConfig(
387+
parent: Agent,
388+
child: Agent,
389+
profile: ResolvedAgentProfile | undefined,
390+
options: Pick<RunSubagentOptions, 'modelAlias' | 'thinkingLevel'>,
391+
): { modelAlias: string | undefined; thinkingLevel: string | undefined; fastMode: boolean } {
392+
const requested = options.modelAlias ?? profile?.model;
393+
const modelAlias =
394+
requested !== undefined && child.config.canResolveModel(requested)
395+
? requested
396+
: parent.config.modelAlias;
397+
return {
398+
modelAlias,
399+
thinkingLevel: options.thinkingLevel ?? profile?.effort ?? parent.config.thinkingLevel,
400+
fastMode: parent.config.fastMode,
401+
};
402+
}
403+
404+
private tryResolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile | undefined {
405+
try {
406+
return this.resolveProfile(parent, profileName);
407+
} catch {
408+
return undefined;
409+
}
410+
}
411+
377412
private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile {
378413
const configuredProfiles = this.session.agentProfiles;
379414
const profile =
@@ -484,9 +519,7 @@ export class SessionSubagentHost {
484519
child.setKaos(child.kaos.withCwd(cwd));
485520
child.config.update({
486521
cwd,
487-
modelAlias: options.modelAlias ?? profile?.model ?? parent.config.modelAlias,
488-
thinkingLevel: options.thinkingLevel ?? profile?.effort ?? parent.config.thinkingLevel,
489-
fastMode: parent.config.fastMode,
522+
...this.childModelConfig(parent, child, profile, options),
490523
});
491524

492525
if (options.forkContext === true) {

‎packages/agent-core/test/session/subagent-host.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,58 @@ describe('SessionSubagentHost', () => {
13711371
expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias);
13721372
expect(child.agent.config.modelAlias).not.toBe('stale-model-from-initial-spawn');
13731373
});
1374+
1375+
it('keeps a profile-routed model and effort across resume', async () => {
1376+
const parent = testAgent();
1377+
parent.configure();
1378+
parent.agent.permission.setMode('yolo');
1379+
1380+
const child = testAgent();
1381+
child.configure({ tools: ['Read'] });
1382+
// Register a second alias so the child's provider can resolve the model the
1383+
// profile routes to (in production this is a [models."..."] config entry
1384+
// that may point at an entirely different provider).
1385+
child.configureRuntimeModel({ type: 'pythinker', apiKey: 'test-key', model: 'implementer-model' });
1386+
child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]);
1387+
child.mockNextResponse({
1388+
type: 'text',
1389+
text: 'Resumed the routed subagent from its earlier context and carried the assigned task through to completion, then reported a full and detailed technical summary of every change so the parent agent can continue without repeating any prior work.',
1390+
});
1391+
1392+
const implementerProfile: ResolvedAgentProfile = {
1393+
name: 'implementer',
1394+
description: 'Cheap implementer routed to another model.',
1395+
systemPrompt: () => 'implementer system prompt',
1396+
tools: ['Read'],
1397+
model: 'implementer-model',
1398+
effort: 'medium',
1399+
};
1400+
child.agent.useProfile(implementerProfile);
1401+
1402+
const session = Object.assign(
1403+
fakeSession(parent.agent, child.agent, {
1404+
'agent-0': { type: 'sub', parentAgentId: 'main' },
1405+
}),
1406+
{ agentProfiles: { implementer: implementerProfile } },
1407+
);
1408+
const host = new SessionSubagentHost(session, 'main');
1409+
1410+
const handle = await host.resume('agent-0', {
1411+
parentToolCallId: 'call_agent',
1412+
prompt: 'Continue from context',
1413+
description: 'Continue work',
1414+
runInBackground: false,
1415+
signal,
1416+
});
1417+
await handle.completion;
1418+
1419+
// Resume must re-resolve through the spawn precedence rather than copying
1420+
// the parent's model, or a routed implementer silently reverts to the
1421+
// orchestrator's (expensive) model on its second turn.
1422+
expect(child.agent.config.modelAlias).toBe('implementer-model');
1423+
expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias);
1424+
expect(child.agent.config.thinkingLevel).toBe('medium');
1425+
});
13741426
});
13751427

13761428
describe('Session resume permission parent chain', () => {

0 commit comments

Comments
 (0)