Skip to content

Commit cf5b6b1

Browse files
authored
fix(agent-core): keep profile-routed subagent model on resume and retry (#28)
## Related Issue No issue — problem described below. ## Problem An agent profile can route its subagents to a different model (a cheap implementer under an expensive orchestrator, for example). That routing only held on the initial spawn: resuming or retrying a subagent copied the parent agent's model alias onto the child, so from the second turn on the subagent silently ran on the orchestrator's model and the profile's effort setting was dropped. ## What changed Resume and retry now re-resolve the child's model through the same precedence as spawn — explicit run option, then profile, then parent — instead of copying the parent's model. An alias the child's provider cannot resolve falls back to the parent's model rather than failing at generate time. Added a regression test covering resume with a profile-routed model and effort. ## Checklist - [x] I have read the CONTRIBUTING document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. (internal behavior, no CLI docs surface) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Subagents now retain their assigned model and effort settings when resumed or retried. * Invalid or unavailable model aliases gracefully fall back to the parent agent’s model. * Profile configuration issues no longer prevent subagent resume or retry operations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 251fb87 commit cf5b6b1

4 files changed

Lines changed: 160 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Keep a subagent on the model and effort its profile assigns when the subagent is resumed or retried, instead of reverting it to the main agent's model.

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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,6 +1371,106 @@ 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+
});
1426+
1427+
it('keeps a profile-routed model and effort across retry', async () => {
1428+
const parent = testAgent();
1429+
parent.configure();
1430+
parent.agent.permission.setMode('yolo');
1431+
1432+
const child = testAgent();
1433+
child.configure({ tools: ['Read'] });
1434+
child.configureRuntimeModel({ type: 'pythinker', apiKey: 'test-key', model: 'implementer-model' });
1435+
child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]);
1436+
child.mockNextResponse({
1437+
type: 'text',
1438+
text: 'Retried 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.',
1439+
});
1440+
1441+
const implementerProfile: ResolvedAgentProfile = {
1442+
name: 'implementer',
1443+
description: 'Cheap implementer routed to another model.',
1444+
systemPrompt: () => 'implementer system prompt',
1445+
tools: ['Read'],
1446+
model: 'implementer-model',
1447+
effort: 'medium',
1448+
};
1449+
child.agent.useProfile(implementerProfile);
1450+
1451+
const session = Object.assign(
1452+
fakeSession(parent.agent, child.agent, {
1453+
'agent-0': { type: 'sub', parentAgentId: 'main' },
1454+
}),
1455+
{ agentProfiles: { implementer: implementerProfile } },
1456+
);
1457+
const host = new SessionSubagentHost(session, 'main');
1458+
1459+
const handle = await host.retry('agent-0', {
1460+
parentToolCallId: 'call_agent',
1461+
prompt: 'Continue from context',
1462+
description: 'Continue work',
1463+
runInBackground: false,
1464+
signal,
1465+
});
1466+
await handle.completion;
1467+
1468+
// Retry re-runs the last turn, so it must re-resolve the routed model the
1469+
// same way resume does instead of inheriting the parent's.
1470+
expect(child.agent.config.modelAlias).toBe('implementer-model');
1471+
expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias);
1472+
expect(child.agent.config.thinkingLevel).toBe('medium');
1473+
});
13741474
});
13751475

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

0 commit comments

Comments
 (0)