diff --git a/.changeset/keep-subagent-routed-model.md b/.changeset/keep-subagent-routed-model.md new file mode 100644 index 00000000..8a037fac --- /dev/null +++ b/.changeset/keep-subagent-routed-model.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +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. diff --git a/packages/agent-core/src/agent/config/index.ts b/packages/agent-core/src/agent/config/index.ts index eed38ec1..be16ab8f 100644 --- a/packages/agent-core/src/agent/config/index.ts +++ b/packages/agent-core/src/agent/config/index.ts @@ -169,6 +169,11 @@ export class ConfigState { : createProvider(providerConfig).supportsFastMode === true; } + /** Whether this agent's provider can resolve `modelAlias` at all. */ + canResolveModel(modelAlias: string | undefined): boolean { + return this.tryResolveProviderFor(modelAlias) !== undefined; + } + get profileName(): string | undefined { return this._profileName; } @@ -195,8 +200,13 @@ export class ConfigState { } private tryResolvedProviderConfig(): ResolvedRuntimeProvider | undefined { + return this.tryResolveProviderFor(this._modelAlias); + } + + private tryResolveProviderFor(modelAlias: string | undefined): ResolvedRuntimeProvider | undefined { + if (modelAlias === undefined) return undefined; try { - return this.resolvedProviderConfig; + return this.agent.modelProvider?.resolveProviderConfig(modelAlias); } catch { return undefined; } diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index a4eff59a..d9e1d554 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -238,10 +238,9 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { this.emitSubagentSpawned(parent, agentId, profileName, runOptions); try { - child.config.update({ - modelAlias: parent.config.modelAlias, - fastMode: parent.config.fastMode, - }); + child.config.update( + this.childModelConfig(parent, child, this.tryResolveProfile(parent, profileName), runOptions), + ); return await this.runPromptTurn(parent, agentId, child, profileName, runOptions); } catch (error) { this.emitSubagentFailed(parent, agentId, runOptions, error); @@ -257,10 +256,9 @@ export class SessionSubagentHost { const completion = this.runWithActiveChild(agentId, options, async (runOptions) => { try { runOptions.signal.throwIfAborted(); - child.config.update({ - modelAlias: parent.config.modelAlias, - fastMode: parent.config.fastMode, - }); + child.config.update( + this.childModelConfig(parent, child, this.tryResolveProfile(parent, profileName), runOptions), + ); this.emitSubagentStarted(parent, agentId, runOptions.parentToolCallId); const turnId = child.turn.retry('agent-host'); if (turnId === null) { @@ -374,6 +372,43 @@ export class SessionSubagentHost { return metadata.dynamicWorkflowItem; } + /** + * Model selection for a child: explicit option → profile → parent. Resume and + * retry re-resolve through the same precedence, so a profile that routes its + * subagents to another model (and provider) is not silently replaced by the + * parent's model on the second turn. + * + * An alias the provider cannot resolve (e.g. a typo, or a session built on + * SingleModelProvider) falls back to the parent's model instead of failing at + * generate time. fastMode stays a straight inherit: it is a preference the + * provider layer already drops when the active model cannot serve it. + */ + private childModelConfig( + parent: Agent, + child: Agent, + profile: ResolvedAgentProfile | undefined, + options: Pick, + ): { modelAlias: string | undefined; thinkingLevel: string | undefined; fastMode: boolean } { + const requested = options.modelAlias ?? profile?.model; + const modelAlias = + requested !== undefined && child.config.canResolveModel(requested) + ? requested + : parent.config.modelAlias; + return { + modelAlias, + thinkingLevel: options.thinkingLevel ?? profile?.effort ?? parent.config.thinkingLevel, + fastMode: parent.config.fastMode, + }; + } + + private tryResolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile | undefined { + try { + return this.resolveProfile(parent, profileName); + } catch { + return undefined; + } + } + private resolveProfile(parent: Agent, profileName: string): ResolvedAgentProfile { const configuredProfiles = this.session.agentProfiles; const profile = @@ -484,9 +519,7 @@ export class SessionSubagentHost { child.setKaos(child.kaos.withCwd(cwd)); child.config.update({ cwd, - modelAlias: options.modelAlias ?? profile?.model ?? parent.config.modelAlias, - thinkingLevel: options.thinkingLevel ?? profile?.effort ?? parent.config.thinkingLevel, - fastMode: parent.config.fastMode, + ...this.childModelConfig(parent, child, profile, options), }); if (options.forkContext === true) { diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index de850094..2e087652 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -1371,6 +1371,106 @@ describe('SessionSubagentHost', () => { expect(child.agent.config.modelAlias).toBe(parent.agent.config.modelAlias); expect(child.agent.config.modelAlias).not.toBe('stale-model-from-initial-spawn'); }); + + it('keeps a profile-routed model and effort across resume', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent(); + child.configure({ tools: ['Read'] }); + // Register a second alias so the child's provider can resolve the model the + // profile routes to (in production this is a [models."..."] config entry + // that may point at an entirely different provider). + child.configureRuntimeModel({ type: 'pythinker', apiKey: 'test-key', model: 'implementer-model' }); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + child.mockNextResponse({ + type: 'text', + 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.', + }); + + const implementerProfile: ResolvedAgentProfile = { + name: 'implementer', + description: 'Cheap implementer routed to another model.', + systemPrompt: () => 'implementer system prompt', + tools: ['Read'], + model: 'implementer-model', + effort: 'medium', + }; + child.agent.useProfile(implementerProfile); + + const session = Object.assign( + fakeSession(parent.agent, child.agent, { + 'agent-0': { type: 'sub', parentAgentId: 'main' }, + }), + { agentProfiles: { implementer: implementerProfile } }, + ); + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.resume('agent-0', { + parentToolCallId: 'call_agent', + prompt: 'Continue from context', + description: 'Continue work', + runInBackground: false, + signal, + }); + await handle.completion; + + // Resume must re-resolve through the spawn precedence rather than copying + // the parent's model, or a routed implementer silently reverts to the + // orchestrator's (expensive) model on its second turn. + expect(child.agent.config.modelAlias).toBe('implementer-model'); + expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); + expect(child.agent.config.thinkingLevel).toBe('medium'); + }); + + it('keeps a profile-routed model and effort across retry', async () => { + const parent = testAgent(); + parent.configure(); + parent.agent.permission.setMode('yolo'); + + const child = testAgent(); + child.configure({ tools: ['Read'] }); + child.configureRuntimeModel({ type: 'pythinker', apiKey: 'test-key', model: 'implementer-model' }); + child.agent.context.appendUserMessage([{ type: 'text', text: 'Earlier context' }]); + child.mockNextResponse({ + type: 'text', + 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.', + }); + + const implementerProfile: ResolvedAgentProfile = { + name: 'implementer', + description: 'Cheap implementer routed to another model.', + systemPrompt: () => 'implementer system prompt', + tools: ['Read'], + model: 'implementer-model', + effort: 'medium', + }; + child.agent.useProfile(implementerProfile); + + const session = Object.assign( + fakeSession(parent.agent, child.agent, { + 'agent-0': { type: 'sub', parentAgentId: 'main' }, + }), + { agentProfiles: { implementer: implementerProfile } }, + ); + const host = new SessionSubagentHost(session, 'main'); + + const handle = await host.retry('agent-0', { + parentToolCallId: 'call_agent', + prompt: 'Continue from context', + description: 'Continue work', + runInBackground: false, + signal, + }); + await handle.completion; + + // Retry re-runs the last turn, so it must re-resolve the routed model the + // same way resume does instead of inheriting the parent's. + expect(child.agent.config.modelAlias).toBe('implementer-model'); + expect(child.agent.config.modelAlias).not.toBe(parent.agent.config.modelAlias); + expect(child.agent.config.thinkingLevel).toBe('medium'); + }); }); describe('Session resume permission parent chain', () => {