feat(agent-core): per-workspace subagent model bindings#1928
Conversation
🦋 Changeset detectedLatest commit: a90501a The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ccf0f39e9d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const { child, profileName } = await this.ensureIdleSubagent(agentId, parent); | ||
| // Sticky semantics: a resumed child always keeps its configured | ||
| // model/effort; mid-conversation model switches are not supported. | ||
| const modelAlias = this.resolveChildModel(parent, child.config.modelAlias); |
There was a problem hiding this comment.
Gate sticky resume behind the experiment
When the experiment is off, this still changes the default resume path: the only flag check is in AgentTool's binding lookup, but Agent(resume=...) now resolves the child's persisted alias instead of the current parent alias. A user who changes or removes the main model and then resumes an old subagent will keep the stale child model or get CONFIG_INVALID, whereas the pre-change code realigned to the parent; gate sticky resume/retry with subagent-model-selection or keep the old realignment for unbound children.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — the sticky resume was applying unconditionally, which contradicted the "default behavior unchanged" claim. Fixed: resume()/retry() now check parent.experimentalFlags.enabled('subagent-model-selection'). With the flag on, a resumed child keeps its configured model/effort (sticky, no mid-conversation switches; unresolvable stored aliases fail fast with CONFIG_INVALID). With the flag off, resume realigns the child to the parent's current model exactly as before, byte-for-byte the old behavior — effort is untouched on resume in both modes. Added test coverage for both the flag-off realign path and the flag-on sticky/error paths.
| const operation = resumeAgentId !== undefined && resumeAgentId.length > 0 ? 'resume' : 'spawn'; | ||
| // Workspace model binding (experiment-gated): applied mechanically at | ||
| // spawn; the first unbound spawn may ask the user once interactively. | ||
| const binding = await this.resolveSpawnBinding(requestedProfileName ?? 'coder', operation, true); |
There was a problem hiding this comment.
Apply workspace bindings to AgentSwarm launches
With workspace binding enabled, this is the only path that reads or asks for .kimi-code/local.toml bindings. AgentSwarm launches item-based subagents through AgentSwarmTool → SessionSubagentHost.runQueued() → spawn() without going through this code or passing modelAlias/thinkingEffort, so /subagent-model set coder is ignored for swarm-spawned coder agents even though the feature is described as applying to every new subagent of that type. Move binding resolution into the shared spawn path or wire it through AgentSwarm too.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct — AgentSwarm launches go through runQueued() and never see the bindings. For this PR we've deliberately scoped bindings to Agent-tool spawns only and narrowed the docs accordingly (bindings are applied to subagents spawned via the Agent tool; AgentSwarm support is future work). Rationale: swarm model routing deserves its own design (per-item vs per-batch bindings, ramp behavior), and we'd rather not ship a half-considered version of it here. If maintainers prefer, a clean follow-up is to move binding resolution into the shared SessionSubagentHost.spawn path so swarm benefits mechanically with no swarm-specific code — happy to do that in a separate PR.
| if (target.modelAlias === undefined && subagent.modelAlias !== undefined) { | ||
| target.modelAlias = subagent.modelAlias; |
There was a problem hiding this comment.
Store profile overrides on the subagent edge
Profile bindings are properties of an owner's subagents entry, but this mutates the shared resolved target profile. If two owner profiles bind the same subagent type differently, whichever owner is processed first permanently sets target.modelAlias, so later owners silently run that subagent on the wrong model. Keep the override on the owner-to-subagent link instead of the global target profile.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reply: profile overrides are stored on the shared resolved target
Thanks for the careful read — the mechanism is described accurately. Sharing our analysis of why we kept it, and what the actual blast radius is.
What the mechanism is
A model binding declared in an owner's subagents: entry is conceptually a property of the owner → subagent edge ("when this owner delegates to coder, use model M"). applySubagentOverrides instead writes it onto the shared resolved target profile (coder.modelAlias = ...), because every subagent type resolves to a single global ResolvedAgentProfile object.
When it would break
The failure mode requires two owner profiles binding the same subagent type differently:
# agent.yaml
subagents:
coder:
modelAlias: cheap-model
# researcher.yaml
subagents:
coder:
modelAlias: strong-modelBoth entries point at the same coder object; the first owner processed wins, and the second owner's binding is silently skipped. Codex's point is exactly right about this.
Why we assess it as low-risk in practice
- Unreachable with shipped configuration. The bundled profiles have exactly one owner (
agent.yaml) declaring subagent entries. No second owner exists, so the conflict cannot occur out of the box. - The pattern is pre-existing, not introduced here. The
descriptionfield has used the identical shared-target mechanism sinceapplySubagentDescriptions. This PR extends an established pattern rather than inventing a new one — if the shared-target hazard ever becomes real, it needs solving fordescriptionandmodelAlias/thinkingEfforttogether. - Bounded impact. The worst case is a subagent running on an unintended model — a configuration surprise, not data loss or a correctness failure, and it is corrected by editing one binding.
- Discovery cost is low by design of this PR. The effective model is now visible in the tool result (
model:line), thesubagent.spawnedevent payload, andAgentBackgroundTaskInfo. A wrong-model outcome is observable immediately rather than silent.
What we did about it
- Added a code comment on
applySubagentOverridesdocumenting the shared-target limitation and the direction of the proper fix (moving overrides onto the owner→subagent edge). - We explicitly chose not to do the edge-storage refactor in this PR: it would change the shape of
ResolvedAgentProfile.subagentsand every consumer for a scenario that is currently unreachable.
If maintainers disagree
Two clean fallbacks, and we're happy to take either:
- Edge storage: keep
{ profile, modelAlias, thinkingEffort }per owner→subagent link and resolve per-owner at spawn time. - Drop profile bindings from this PR: the workspace
local.tomlbindings are the core feature and cover the user need; profile-level bindings were included for completeness and can be split into a follow-up.
…xperiment Address review feedback on MoonshotAI#1928: - resume/retry only keep the child's configured model when subagent-model-selection is enabled; with the experiment off, resume realigns to the parent's current model exactly as before - spawn-time profile modelAlias/thinkingEffort bindings are likewise ignored unless the experiment is enabled - docs: workspace bindings apply to Agent tool spawns only; AgentSwarm does not read bindings yet (swarm-wide routing is future work) - note the shared-target limitation of profile override resolution
d5dca1f to
9bd3cb2
Compare
|
Thanks — the experiment-gating part looks fixed, but I think two original review points are still unresolved.
|
|
Thanks for the work here — the overall direction looks good, but I think there’s still a remaining freshness gap around From the PR notes, this path can stall for >45s on macOS under multi-instance fs load because it still depends on fs event delivery. I agree that Suggested fix:
|
Thanks for pushing on this — you're right that narrowing the docs doesn't change behavior, and I appreciate the concrete suggestion. |
This looks unrelated to this PR's scope (per-workspace subagent model bindings) — the session-list freshness/watch mechanism isn't touched here and deserves its own issue or PR. Flagging so it doesn't get lost |
…xperiment Address review feedback on MoonshotAI#1928: - resume/retry only keep the child's configured model when subagent-model-selection is enabled; with the experiment off, resume realigns to the parent's current model exactly as before - spawn-time profile modelAlias/thinkingEffort bindings are likewise ignored unless the experiment is enabled - docs: workspace bindings apply to Agent tool spawns only; AgentSwarm does not read bindings yet (swarm-wide routing is future work) - note the shared-target limitation of profile override resolution
9bd3cb2 to
f8293ac
Compare
Hey, hear me out — I actually get your constraint here, and I agree that if the current AgentSwarm shape doesn’t really fit type-level workspace bindings, then it probably doesn’t make sense to force that behavior into this PR just for consistency. So instead of pushing against that, I tried to think about the remaining gaps in a way that works with the direction you described. I came up with three possible ways this could go, and I’m hoping one of them might fit what you want better. I think the underlying issue here is that the current binding model is trying to answer two different questions with one mechanism:
Those are related, but they’re not really the same problem. That’s why I think the remaining gap is less about “apply bindings everywhere” and more about making launch resolution flexible enough to support both cases without locking AgentSwarm into the wrong semantics too early. Option 1: shared launch-config resolver + swarm-aware routingThis feels like the cleanest long-term fix to me. The idea would be to move model/thinking resolution into one shared launch-config resolver that all subagent launches use, but make that resolver capable of accepting swarm-specific routing context. That way:
The reason I like this is that it keeps one shared mechanism for launch resolution, but doesn’t pretend regular subagent spawns and swarm-member launches are the same thing. For example, a normal
That does not fit a single type-level binding for the whole batch, but it does fit a shared resolver that can accept member-specific context. Option 2: fix the correctness bug now, defer full swarm routing cleanlyIf the full swarm-aware routing design is too much for this PR, I still think there’s a meaningful smaller step here. That would be:
What I like about this option is that it fixes the correctness issue that exists today without forcing a half-fitting AgentSwarm change. It wouldn’t solve the full swarm problem yet, but it would still:
Option 3: introduce an explicit swarm-routing abstractionAnother option might be to create a small abstraction boundary specifically for swarm routing instead of trying to stretch the current binding model to cover it. For example:
That policy could stay minimal for now:
The reason I think this might work well is that it gives you a clean extension point for the richer swarm behavior you said you’re already exploring, without hardcoding today’s minimal batch semantics too deeply into the long-term design. For example, today it could just say:
Later it could evolve into:
That gives you room to grow without pretending the current type-level binding model already solves the swarm problem. The one part that still feels important either wayIndependent of the AgentSwarm question, the shared-target override issue still looks like a real correctness bug today. So even if full swarm routing is intentionally deferred, I do think it would still be worth fixing the owner->subagent override scoping now:
That part feels independently fixable and worth addressing even if the full swarm story comes later. Tests I’d expect either wayWhichever direction you go, I’d want to see regression coverage proving that:
OverallSo I don’t think the remaining issue is really “please apply the current binding model to AgentSwarm as-is.” It feels more like:
If you want the cleanest long-term outcome, I’d lean toward Option 1. |
|
Thanks for laying out the three options — and for framing it as two separate questions; that's the right split. On the shared-target override: confirmed, and the code already confesses it. On Option 1 — I have to admit this gave me a genuine double-take. You independently described, almost feature for feature, something we've already built on a stacked branch ( On the stacked branch it works like this:
Why it's not in this PR: we're holding it while finishing a port of the hermes MoA (mixture-of-agents) orchestration flow onto kimi-code — orchestrator → collectors → debaters → aggregators is the primary consumer of instance-level routing, and we'd rather land the routing design once, informed by that real usage, than ship the parameter now and revise it when the swarm design settles. It'll come as a follow-up PR after this one merges; happy to link the branch earlier if you'd like a look. Separately: the |
Add an experimental subagent-model-selection feature (off by default, KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION=1) that binds configured model aliases and thinking efforts to subagent types per workspace: - Bindings live in <projectRoot>/.kimi-code/local.toml [subagent.<type>] and are applied mechanically at spawn (workspace binding > profile binding > parent inheritance); the calling agent never sees or overrides them, removing LLM discretion from model routing - First unbound spawn of a type asks the user once interactively and persists the answer, including an explicit keep-inheriting choice - /subagent-model command (list/set/clear) manages bindings via a new session RPC chain (agent-core -> node-sdk -> TUI) - Resume/retry are sticky: a subagent always keeps the model/effort it was configured with (persisted via config.update wire records); no mid-conversation model switches. Unresolvable stored aliases fail fast with a configuration error instead of silently realigning - Agent profiles can bind modelAlias/thinkingEffort per subagent type - Effective model/effort are exposed in the subagent.spawned event, AgentBackgroundTaskInfo, approval label, and tool result text
…xperiment Address review feedback on MoonshotAI#1928: - resume/retry only keep the child's configured model when subagent-model-selection is enabled; with the experiment off, resume realigns to the parent's current model exactly as before - spawn-time profile modelAlias/thinkingEffort bindings are likewise ignored unless the experiment is enabled - docs: workspace bindings apply to Agent tool spawns only; AgentSwarm does not read bindings yet (swarm-wide routing is future work) - note the shared-target limitation of profile override resolution
…del aliases When a stored subagent binding references a model alias missing from the user's models config, interactive spawns re-ask with the missing-model reason and persist the repaired binding; non-interactive spawns inherit the main agent model with an explicit warning in the tool result.
applySubagentOverrides mutated the shared resolved target profile, so
once two owners bound the same subagent type differently the first
processed owner would win for all of them. linkResolvedSubagents now
builds an edge-scoped view of the target ({...target, overrides})
instead of mutating shared state. Single-owner behavior is unchanged;
adds regression coverage for two owners binding the same type.
… dismissed In non-interactive sessions the question channel exists but is never answered (e.g. kimi -p), so the missing-model re-ask always dismisses — previously that path inherited the parent model silently. A dismissed re-ask now emits the same explicit warning as the no-ask path.
78c6093 to
a90501a
Compare
Related Issue
Resolve #1927
Problem
See linked issue.
What changed
Bindings belong to the user, not the model. Model selection for delegated work is treated as user configuration, applied mechanically — the calling agent never sees or overrides it. This deliberately avoids LLM-facing model directories/parameters: in testing, agents would self-fill a model argument in ways that silently overrode user intent.
<projectRoot>/.kimi-code/local.tomlunder[subagent.<type>], following the existingworkspace.additional_dirpattern/subagent-modelcommand (list/set <type>/clear <type>) via a new session RPC chain (agent-core → node-sdk → TUI)modelAlias/thinkingEfforton profilesubagentsentries) > parent inheritanceconfig.updatewire records); no mid-conversation switches. Unresolvable stored aliases fail fast with a configuration error instead of silently realigning to the parent — a deliberate behavior change from today's realign-on-resumesubagent.spawnedevent,AgentBackgroundTaskInfo, approval label, and tool result textsubagent-model-selectionexperiment (off by default,KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION=1), so default behavior is unchangedValidation
/subagent-modelflows — all passing/bin/bash, symlink/UNC semantics)tsc --noEmitclean (agent-core, protocol, node-sdk, apps/kimi-code); oxlint 0 errors in touched files/subagent-modellist/clear, all verifiedChecklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.