feat(agent-core): dual-model-routing — separate subagent model and thinking effort#1996
feat(agent-core): dual-model-routing — separate subagent model and thinking effort#1996kassieclaire wants to merge 8 commits into
Conversation
…e subagent models Add the dual-model-routing experimental flag so the main agent and its subagents can run on different models (e.g. Kimi K3 for the main agent, GLM-5.2 for subagents). When enabled (default off), subagents use a dedicated subagent model instead of inheriting the parent agent model: - Config: new default_subagent_model field (config.toml + patch schema) - Runtime: SessionSubagentHost resolves the subagent model from the live session override or config default, gated by the flag; all four subagent creation paths (spawn, resume, retry, btw) honor it - RPC: Session-level setSubagentModel/getSubagentModel added to the SessionAPI surface; SessionStatus carries subagentModel - SDK: Session.setSubagentModel + SessionStatus.subagentModel exposed - TUI: /model opens a scope picker (Main agent / Subagents) when the flag is on; the footer shows both models; state is hydrated on login, resume, reload, and experiment toggles When disabled, all UI and runtime behavior is unchanged — subagents inherit the parent model, the footer shows one model, and /model keeps its singular behavior.
…ng hardening Extend the dual-model-routing experimental feature so subagents can run a dedicated thinking effort alongside the dedicated model, and harden the original routing based on review: - Thinking effort: new default_subagent_thinking_effort config field, live session override, setSubagentThinking/getSubagentThinking RPCs, SDK Session.setSubagentThinkingEffort + SessionStatus.subagentThinkingEffort. The /model subagent picker applies the selected effort (previously silently dropped), and the footer badge / scope picker display it. - btw exception: the /btw side-question agent always inherits the main agent's model and effort, even when the flag is on. It replays the main agent's prompt prefix and shares its warm prefix cache, so rerouting it would re-process the whole conversation on a cold cache for a lightweight text-only side channel. - Validation: setSubagentModel validates the alias eagerly via the provider manager (mirroring Agent.setModel) instead of failing deep inside a later subagent spawn; both subagent setters reject with CONFIG_INVALID when the flag is off rather than storing dormant values. - Persistence: live subagent overrides are written to session metadata (custom) and rehydrated on resume, so they survive /reload, resume, and fork like the main model does. - SDK: getStatus guards the new RPCs with typeof checks so it keeps working against older cores that lack them. - TUI: footer badge only renders when the subagent model is distinct from the main model; logout/provider-removal clears subagent state; the subagent picker drops the misleading main-conversation cache warning; no-op selections short-circuit. - Tests: flag-on routing across spawn/resume/retry, btw inheritance with a subagent model configured, Session RPC surface (round-trip, clear, invalid alias, flag-off), getStatus against older cores, scope selector and footer components, experiment-toggle hydration. Also fix the node-sdk experimental-feature list assertion that was failing at HEAD (missing dual-model-routing entry).
Mirror the English docs for the dual-model-routing experimental feature: default_subagent_model / default_subagent_thinking_effort rows plus the tip block (btw exception, session create/resume semantics) in config-files.md, and the KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING row in env-vars.md.
🦋 Changeset detectedLatest commit: 30f7418 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Adds an opt-in experimental feature (dual-model-routing) to route delegated subagents (spawn/resume/retry) to a dedicated model and thinking-effort, configurable via new config defaults and session-level RPC/SDK setters, with TUI support and docs updates. This fits the codebase’s ongoing work on model routing controls while keeping default behavior unchanged when the flag is off.
Changes:
- Add new config fields (
default_subagent_model,default_subagent_thinking_effort) plus TOML/schema round-trips and session persistence for live overrides. - Add session RPC + Node SDK support for getting/setting subagent model/thinking (with older-core back-compat in
getStatus). - Update the TUI
/modelflow to support main vs subagent scope selection and render a subagent badge when appropriate; add tests and docs.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/node-sdk/test/session-get-status.test.ts | Adds coverage for getStatus behavior with/without subagent RPCs. |
| packages/node-sdk/test/config.test.ts | Registers the new experimental flag in harness config tests. |
| packages/node-sdk/src/types.ts | Extends SessionStatus with subagentModel and subagentThinkingEffort. |
| packages/node-sdk/src/session.ts | Adds Session.setSubagentModel / setSubagentThinkingEffort helpers. |
| packages/node-sdk/src/rpc.ts | Defines new subagent RPC types/methods and guards getStatus for older cores. |
| packages/agent-core/test/session/subagent-host.test.ts | Adds test coverage for model/effort separation and /btw inheritance exception. |
| packages/agent-core/test/session/init.test.ts | Adds round-trip + gating tests for new session RPCs without spinning up a full agent. |
| packages/agent-core/test/flags/resolver.test.ts | Ensures the new flag is registered and env-driven. |
| packages/agent-core/test/config/configs.test.ts | Tests TOML parse/serialize for new config fields. |
| packages/agent-core/src/session/subagent-host.ts | Routes delegated subagents through new model/effort resolvers; documents /btw exception. |
| packages/agent-core/src/session/rpc.ts | Implements set/getSubagentModel and set/getSubagentThinking RPC methods with flag gating. |
| packages/agent-core/src/session/index.ts | Persists and rehydrates live subagent overrides from session metadata. |
| packages/agent-core/src/rpc/core-impl.ts | Wires new subagent RPC methods into the core RPC surface. |
| packages/agent-core/src/rpc/core-api.ts | Adds the new session-level RPC payload/result types. |
| packages/agent-core/src/flags/registry.ts | Registers dual-model-routing experimental flag metadata. |
| packages/agent-core/src/config/toml.ts | Adds new fields to scalar TOML serialization. |
| packages/agent-core/src/config/schema.ts | Adds new config + patch schema fields for subagent defaults. |
| docs/zh/configuration/env-vars.md | Documents the new env var for enabling the experiment. |
| docs/zh/configuration/config-files.md | Documents new config keys and behavior notes (incl. /btw exception). |
| docs/en/configuration/env-vars.md | Documents the new env var for enabling the experiment. |
| docs/en/configuration/config-files.md | Documents new config keys and behavior notes (incl. /btw exception). |
| apps/kimi-code/test/tui/components/dialogs/model-scope-selector.test.ts | Adds tests for the new model scope picker component. |
| apps/kimi-code/test/tui/components/chrome/footer.test.ts | Adds tests for conditional subagent badge rendering. |
| apps/kimi-code/test/tui/commands/experiments.test.ts | Extends experiment toggle tests to cover subagent state sync paths. |
| apps/kimi-code/src/tui/types.ts | Extends AppState with subagentModel and subagentThinkingEffort. |
| apps/kimi-code/src/tui/kimi-tui.ts | Hydrates subagent model/effort from SessionStatus during session view reload. |
| apps/kimi-code/src/tui/controllers/auth-flow.ts | Initializes/clears subagent model/effort during auth/login/logout flows (flag-aware). |
| apps/kimi-code/src/tui/components/index.ts | Exports the new dialog component. |
| apps/kimi-code/src/tui/components/dialogs/model-scope-selector.ts | Implements the main/subagent scope picker used by /model when flag is on. |
| apps/kimi-code/src/tui/components/chrome/footer.ts | Adds subagent badge rendering logic (flag-aware). |
| apps/kimi-code/src/tui/commands/config.ts | Updates /model to select scope first and adds subagent model picker + persistence logic. |
| .changeset/dual-model-routing.md | Adds minor changeset entries describing the new feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const mainName = modelDisplayName(opts.currentModel, opts.availableModels[opts.currentModel]); | ||
| let subagentName: string; | ||
| if (opts.currentSubagentModel !== undefined && opts.currentSubagentModel.length > 0) { | ||
| const model = modelDisplayName( | ||
| opts.currentSubagentModel, | ||
| opts.availableModels[opts.currentSubagentModel], | ||
| ); | ||
| const effortSuffix = | ||
| opts.currentSubagentThinkingEffort !== undefined && opts.currentSubagentThinkingEffort.length > 0 | ||
| ? ` (effort: ${opts.currentSubagentThinkingEffort})` | ||
| : ''; | ||
| subagentName = `${model}${effortSuffix}`; | ||
| } else { | ||
| subagentName = '(inherits main model)'; | ||
| } |
There was a problem hiding this comment.
Fixed in 142292e — when the model is inherited but an effort is configured, the label now reads (inherits main model, effort: high). Covered by a new test in model-scope-selector.test.ts.
| private persistSubagentOverrides(): void { | ||
| const custom: Record<string, unknown> = { ...this.metadata.custom }; | ||
| if (this.subagentModelAlias !== undefined) { |
There was a problem hiding this comment.
This is a false positive for plain objects: { ...undefined } is a no-op in JS (produces {}), so { ...this.metadata.custom } with custom === undefined does not throw — it yields an empty object. The field is also typed as a required Record<string, any> (SessionMeta.custom, session/index.ts:140) and initialized to {} (session/index.ts:195). I added a regression test (setSubagentModel persists when session metadata has no custom map) that explicitly sets custom: undefined and asserts the setter rebuilds the map instead of throwing.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 86e052d17e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const effortChanged = config.defaultSubagentThinkingEffort !== effort; | ||
| if (!modelChanged && !effortChanged) return false; | ||
| await host.harness.setConfig({ | ||
| ...(modelChanged ? { defaultSubagentModel: alias } : {}), |
There was a problem hiding this comment.
Strip env-only aliases before saving subagent defaults
When KIMI_MODEL_* is active, the picker can include the synthetic __kimi_env_model__ alias, and this new persistence path writes that alias directly into default_subagent_model. Unlike defaultModel, the write path does not restore/drop env-injected subagent defaults, so choosing the env model here commits default_subagent_model = "__kimi_env_model__" to config.toml; after restarting without those env vars, delegated subagents resolve an unconfigured alias and fail. Please handle the env alias the same way the main default is stripped/restored before saving.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
fixed in 142292e. stripEnvModelConfig now mirrors the defaultModel handling for defaultSubagentModel: when it points at the env alias, the value is restored from config.raw['default_subagent_model'] (or dropped if raw has none) instead of being persisted. Covered by three new tests in env-model.test.ts (restore-from-raw, drop-when-no-raw, keep-non-alias).
| * `defaultModel` (the main-agent model). Only takes effect while the | ||
| * flag is on; otherwise subagents inherit the parent's live model. | ||
| */ | ||
| defaultSubagentModel: z.string().optional(), |
There was a problem hiding this comment.
Clear subagent defaults when removing provider models
This new defaultSubagentModel alias also needs to participate in provider removal. I checked removeKimiProvider in packages/agent-core/src/rpc/core-impl.ts: it deletes all model aliases for the provider and clears defaultModel when it pointed at one of them, but it never clears defaultSubagentModel. If a user removes a provider that owned the subagent default, the stale alias remains in config; with dual-model-routing enabled and no live override, the next delegated subagent uses that missing alias and fails model/provider resolution. Please clear this field alongside defaultModel when its alias is removed.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 142292e. removeKimiProvider now clears defaultSubagentModel alongside defaultModel when the removal deletes its model alias. Covered by a new test in runtime.test.ts that seeds both defaults, removes the provider owning the subagent model, and asserts the subagent default is cleared on the returned config and absent from the on-disk config.toml.
- Scope picker: show the configured subagent thinking effort even when the model is inherited, instead of a plain "(inherits main model)" label that dropped the effort suffix. - stripEnvModelConfig: restore default_subagent_model from raw when it points at the runtime-only env alias (mirroring default_model), so saving via /model while KIMI_MODEL_* is active cannot persist __kimi_env_model__ to config.toml. - removeKimiProvider: clear default_subagent_model when the removal deletes its model alias (mirroring default_model), so a stale alias cannot fail provider resolution on the next delegated spawn. Tests: scope-selector inherited-effort label, env-alias restore/drop/keep for the subagent default, provider removal clearing the subagent default.
c437157 to
142292e
Compare
Replace placeholder values (262144 / 131072) with the real context window sizes from models.dev: kimi-k3 = 1,048,576, glm-5.2 = 1,000,000. Both are 1M-class models; the old numbers reflected stale data.
The footer badge was gated on model-alias distinctness only, hiding two
real routing differences:
- Same alias, different thinking effort: subagents run at a different
effort than the main agent — now surfaced via the existing effort
suffix ("Kimi K3 · high").
- Same underlying model via a different provider (e.g. kimi-k3 on
managed:kimi-code vs opencode-go): the badge now shows a provider
prefix ("opencode-go/Kimi K3") so the routes are distinguishable.
The distinctness check now compares three dimensions: alias, provider,
and thinking effort. When all three match the main agent the badge
stays hidden as before.
Badge now surfaces effort-only and provider-only routing differencesWhy this commit (
What changed in the gate. The distinctness check now compares three independent dimensions — alias, provider, and thinking effort. The badge is shown when any of the three differs from the main agent; it stays hidden only when all three match (i.e. subagents are genuinely identical to the main agent). Tests added (
|
# Conflicts: # docs/en/configuration/config-files.md
… thinking effort Port the dual-model-routing experimental feature to the v2 agent engine. When the dual-model-routing flag is enabled, delegated subagents (Agent tool spawn/resume, AgentSwarm spawn/resume) run on a dedicated model and thinking effort instead of inheriting the parent's. The /btw side-question agent always inherits (cache affinity). Resolution chain: session metadata override -> [subagent] config default -> parent inheritance. Inert when the flag is off. v2 engine (packages/agent-core-v2): - Flag: register dual-model-routing via registerFlagDefinition (same id/env/default/surface as v1) - Config: extend [subagent] section with defaultSubagentModel and defaultSubagentThinkingEffort - Service: new Session-scoped ISubagentRoutingService that resolves the effective model/effort, persists live overrides to session metadata (custom.subagentModelAlias / custom.subagentThinkingEffort), and loads them on construction - Hooks: Agent tool and Swarm service route model+thinking through the service on spawn and resume (realignChildModel now syncs both fields) - Tests: 16 new tests covering flag off/on, override set/clear, config default priority, metadata persistence, and load-on-construction Protocol (packages/protocol): - Add subagent_model and subagent_thinking_effort to sessionAgentConfigSchema and sessionStatusResponseSchema Server (packages/agent-core-v2/app/sessionLegacy): - Wire subagent_model / subagent_thinking_effort through applyAgentConfig and assembleStatus via ISubagentRoutingService Web UI (apps/kimi-web): - Types, wire mapper, and state composable for the subagent model - Composer subagent model pill (shown only when dual-model-routing flag is enabled), with inline model picker and clear button - i18n strings (en + zh)
Jiliac
left a comment
There was a problem hiding this comment.
A few findings from reading the branch, each verified against the code. Posting inline; hope they're useful.
| // No-op guard: skip the RPC round-trips and status flash when neither the | ||
| // alias nor the effort actually changed. | ||
| if ( | ||
| alias === host.state.appState.subagentModel && |
There was a problem hiding this comment.
The no-op guard in performSubagentModelSwitch returns before the persist block, so a session-only choice can never be promoted to a saved default. Repro: pick a subagent model "session only", then re-open /model → Subagents → same model/effort → "persist as default". The guard (alias === appState.subagentModel && effort === appState.subagentThinkingEffort) is true, so it prints "Already using … for subagents." and persistSubagentModelSelection is never reached; config.toml stays empty. The main-model flow handles exactly this case: performModelSwitch still persists on a runtime no-op and reports "Saved … as default." Suggest only short-circuiting when persist === false.
| : undefined; | ||
| const mainEntry = state.availableModels[state.model]; | ||
| const mainProvider = mainEntry ? effectiveModelAlias(mainEntry).provider : undefined; | ||
| const isDistinct = |
There was a problem hiding this comment.
The footer subagents: badge can never render the "inherits main model, different effort" state. The badge's stated contract (f9473d4) is that it shows when any of alias, provider, or effort differs, but both branches of isDistinct require subagentAlias !== undefined, and subagentModelDisplayName returns '' for an undefined alias. This state is reachable purely from config: with default_subagent_thinking_effort = "high" and no default_subagent_model, Session.getSubagentThinkingEffort() falls back to the config default independently of the model (session/index.ts:638-640), so subagents genuinely run the main model at high, yet no badge ever renders. The scope picker already acknowledges this state with its "(inherits main model, effort: high)" label.
Related: the "same alias, different provider" clause at line 343 is dead code, since an identical alias keys into the same availableModels entry and the providers can't differ there; the provider prefix only ever fires on the different-alias branch.
| return this.getSubagentThinkingEffort() ?? parentThinkingEffort; | ||
| } | ||
|
|
||
| async setSubagentModel(alias: string | undefined): Promise<void> { |
There was a problem hiding this comment.
The agent-core-v2 write path is missing two guards that the agent-core implementation has, so the two engines diverge on the same client input.
-
Flag gating:
agent-core'ssetSubagentModel/setSubagentThinkingthrowCONFIG_INVALIDwhendual-model-routingis off, but inagent-core-v2the flag is only checked in the getters.sessionLegacyService.applyAgentConfigcalls the setters unconditionally, and they persistcustom.subagentModelAliasinto session metadata even when the flag is off, so the write silently succeeds and then activates later if the flag is turned on. -
Alias validation:
agent-coreeagerly resolves the alias so the next spawn fails fast, butagent-core-v2stores any string, andAgentProfileService.bind()doesn't compensate (modelCatalog.get()returningundefineddoesn't throw). A typo'd alias therefore binds silently, fails at the first LLM call, and recurs on every resume because it's persisted.
| /** True when the connected server has the dual-model-routing experimental | ||
| * flag enabled (read from the daemon config's `experimental` map). Gates the | ||
| * subagent model UI in the composer. */ | ||
| const dualModelRoutingEnabled = computed<boolean>( |
There was a problem hiding this comment.
dualModelRoutingEnabled reads config.experimental['dual-model-routing'] from GET /config, which kap-server builds via toConfigResponse(config.getAll()), a pure projection of the persisted [experimental] section with no env overlay. A user who enables the feature via KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING (as env-vars.md documents) gets a server that fully routes subagents, but the web pill never renders. The web UI ends up divergent from both the TUI (which gates on resolved flag state) and the server's own behavior: subagents route, with no indication or control in the web UI. Suggest surfacing resolved flags on the wire, or gating on the status response instead.
| this.emitSubagentSpawned(parent, agentId, profileName, runOptions); | ||
| try { | ||
| child.config.update({ modelAlias: parent.config.modelAlias }); | ||
| child.config.update({ modelAlias: this.resolveSubagentModelAlias(parent), thinkingEffort: this.resolveSubagentThinkingEffort(parent) }); |
There was a problem hiding this comment.
Probably an intentional consistency win, but worth a deliberate call since it technically breaks the "flag off = unchanged everywhere" claim: before this PR, resume and retry realigned only modelAlias to the parent; they now also push thinkingEffort, which with the flag off resolves to the parent's current effort. If the user changed the main agent's effort between spawn and resume, the resumed subagent's effort now follows it, where it previously kept its spawn-time value. If strict flag-off invariance is intended, the effort update could be omitted when getSubagentThinkingEffort() returns undefined.
Related Issue
Resolve #568 — in particular this comment: run the main agent on a top-tier reasoning model while dispatching subagents to faster, cheaper models.
Problem
See linked issue. Subagents always inherit the main agent's model and thinking effort; there is no way to route delegated work to a different model.
What changed
Adds the opt-in
dual-model-routingexperimental feature (default off; enable via/experiments,KIMI_CODE_EXPERIMENTAL_DUAL_MODEL_ROUTING, or the masterKIMI_CODE_EXPERIMENTAL_FLAG). When enabled, delegated subagents run on a dedicated model and thinking effort instead of inheriting the main agent's. When disabled, behavior is unchanged everywhere.default_subagent_modelanddefault_subagent_thinking_effortfields (config.toml schema + patch schema + TOML round-trip).SessionSubagentHostresolves model/effort from the live session override → config default → parent inheritance, gated by the flag, on all delegated creation paths (spawn, resume, retry — includingAgentSwarm, which launches through the same host methods). Live overrides persist into session metadata and survive/reload, resume, and fork.setSubagentModel/getSubagentModel/setSubagentThinking/getSubagentThinkingwith eager alias validation (mirroringAgent.setModel) and a clearCONFIG_INVALIDrejection when the flag is off. The SDK exposesSession.setSubagentModel/setSubagentThinkingEffortandSessionStatus.subagentModel/subagentThinkingEffort;getStatusguards the new RPCs so it keeps working against older cores that lack them./modelopens a scope picker (Main agent / Subagents) when the flag is on; the subagent picker applies both model and effort (persisted as defaults or session-only); the footer shows asubagents:badge only when the subagent model is distinct from the main one; state is hydrated on login, resume, reload, and experiment toggles, and cleared on logout./btwexception: the side-question agent always inherits the main agent's model and effort. It replays the main agent's projected history (same prompt prefix plus a small reminder), so it shares the warm prefix cache — routing it elsewhere would re-process the whole conversation on a cold cache for a lightweight text-only side channel.@moonshot-ai/kimi-codeand@moonshot-ai/kimi-code-sdk.Relation to other open PRs
This takes a different approach from #1841 (an LLM-facing model directory with a
modelargument onAgent/AgentSwarm) and #1928 (per-workspace, per-type bindings inlocal.toml): routing here is purely user-driven session/config state — the model never sees or self-selects it — and it covers thinking effort alongside the model. #589 (/swarm-model) is superseded by the/modelscope picker.Validation
packages/agent-core,packages/node-sdk, andapps/kimi-code. New coverage: flag-on routing on spawn/resume/retry,/btwinheritance with a subagent model configured, RPC round-trip/clear/invalid-alias/flag-off paths,getStatusolder-core back-compat, scope-picker and footer components, config round-trips, experiment-toggle hydration.pnpm run typecheckclean repo-wide;pnpm oxlint0 warnings / 0 errors on all changed files./modelscope picker with the flag enabled.Checklist
gen-changesetsskill, or this PR needs no changeset. (changeset included:.changeset/dual-model-routing.md, minor for both publishable packages)gen-docsskill, or this PR needs no doc update. (docs updated, en + zh)Closes #568 (comment)
Co-authored by forgecode and kimi-code (Kimi K3 and GLM-5.2).