feat(task): optional per-invocation model selector for sub-agent tasks - #3
Closed
leoncheng57 wants to merge 2 commits into
Closed
feat(task): optional per-invocation model selector for sub-agent tasks#3leoncheng57 wants to merge 2 commits into
leoncheng57 wants to merge 2 commits into
Conversation
The task tool previously always resolved the child's model as the
subagent's configured model, falling back to the invoking assistant's
model. A delegating agent had no way to run one individual task on a
different model.
Add an optional `model` parameter in "provider/model" form. Precedence
becomes: explicit per-invocation model > subagent's configured model >
invoking assistant's model. Omitting it preserves today's behaviour
exactly.
The string is parsed with Provider.parseModel, so model ids that
themselves contain slashes (openrouter-style provider/vendor/model) round
trip correctly. Tool metadata keeps emitting the normalized
{ providerID, modelID } object, so downstream consumers are unchanged.
Existence is deliberately not validated here. It is validated downstream
by SessionPrompt.getModel, which is the same point at which an invalid
agent-configured model fails today.
…oint Unit coverage in test/tool/task.test.ts for the tool's own contract: explicit model beating both the invoking assistant's model and the subagent's configured model, an explicit model on a resumed task_id not creating another child and not persisting as that child's default, running and result metadata reporting the selected pair, omission preserving the existing fallback, and slash-containing model ids. Integration coverage in test/session/prompt-effect.test.ts pins where an unknown model actually fails. On the ordinary tool path the child session is created first, the child never reaches the LLM, and the part errors with ProviderModelNotFoundError while carrying no metadata -- unlike the handleSubtask path, which preserves metadata on error. A second test proves an explicit model overrides an agent pinned to a missing model and lets the child run to completion.
This was referenced Aug 26, 2026
Owner
Author
|
Closing as superseded by #4. This branch targeted the fork's #4 re-implements the same capability on |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an optional per-invocation
modelparameter to the nativetasktool, so a delegatingagent can choose the model for an individual sub-agent child instead of always inheriting.
Precedence is now:
model(new)agent.<name>.model)Omitting the parameter preserves today's behaviour exactly — the diff to the resolution
expression is a single added branch:
The string is parsed with
Provider.parseModel, which splits on the first/and rejoins therest, so openrouter-style ids (
openrouter/anthropic/claude-sonnet-4.5) round trip correctly.Tool metadata keeps emitting the normalized
{ providerID, modelID }object in both the runningand result metadata, so downstream consumers are unchanged.
On a
task_idresume the explicit model applies to that invocation only. It is not written to thechild session as a default, and a later invocation of the same
task_idwithoutmodelfalls backto the normal chain. This is asserted, not assumed.
Why this is needed downstream
This is the remaining upstream-blocked scope of
leoncheng57/custom-dca-opencode#90. That appexposes sub-agent delegation as a first-class surface and already renders per-child model
provenance from
metadata.model. Because this change keeps that field's shape identical, theconsuming app needs no code change to benefit: a task launched with an explicit model simply
shows the model that was actually used.
The motivating cases are cost/latency shaping (send a wide read-only exploration to a cheap model
while the parent stays on an expensive one) and capability shaping (send one task to a
longer-context or reasoning model) without having to define a separate agent per model.
Validation timing: deliberately kept late
Decision: do not validate the model inside the tool.
Provider.parseModelis a pure stringsplit that cannot fail; existence is validated downstream by
SessionPrompt.getModel, which is thesame point at which an invalid agent-configured model fails today.
Rationale:
Providerservice state out ofthe tool.
agent-configured model, whose late-failure behaviour is already pinned by
test/session/prompt-effect.test.ts("failed subtask preserves metadata on error tool state").Two divergent failure shapes for the same class of mistake is worse than one late one.
Consequence, stated explicitly rather than left implicit. An invalid explicit model still
creates (or reuses) the child session before the model is rejected, so it leaves an orphaned child
whose first user message can never be answered. On the ordinary tool path the error part is
also stripped of metadata, so the failed part names neither the attempted model nor the child it
created. This is now pinned by a test asserting the real behaviour:
error, message containsProviderModelNotFoundErrortool.state.metadataisundefinedsessions.children(parent)has length 1That last point is a genuine asymmetry worth recording: the
handleSubtaskpath does preservemetadata on error, the ordinary tool-execution path does not. Improving that is a separate change
and would apply to every tool, not just
task.A malformed string with no slash (
"nonsense") does not throw either — it parses to{ providerID: "nonsense", modelID: "" }and fails the same way at lookup.Out of scope (deliberately)
SubtaskPart.model(slash-command model) into this parameter — that would changeestablished command/agent precedence.
handleSubtaskstill builds its owntaskArgsand doesnot pass
model.sdks/,specs/, orpackages/sdkreferences the task tool's parameter names, and the experimental tool-schemaendpoint serializes the Zod schema at runtime.
Verification
All commands run in a dedicated worktree on this branch. No binary was built or installed; nothing
under
~/.opencode/bin, launchd, or any runningopencode servewas touched.Target test file:
Both changed test files:
Full
packages/opencodesuite:Typecheck and lint:
New tests
test/tool/task.test.ts(+7):task_idreaches the existing child, creates no second child, and does not persist as that child's default{ providerID, modelID }openrouter/anthropic/claude-sonnet-4.5keeps the slash in the model idtest/session/prompt-effect.test.ts(+2):ProviderModelNotFoundError),the child never reaches the LLM (
llm.calls === 2), the error part carries no metadata, and theorphaned child exists
and reports the explicit model in its metadata
Risks
its own initiative. There is no model permission or allow-list gate in this PR; the existing
taskpermission still gates whether a subagent may be launched, not on what model. Theparameter description tells the model to use it sparingly, but that is guidance, not enforcement.
an explicit
modeloverrides that pin. The parameter description says so, but a caller can stilldo it. Operators who need a hard pin should treat this as a reason to keep such agents behind a
taskdeny rather than relying on the configured model alone.SessionPrompt.createUserMessageonly carries the agent'svariantthrough when the resolved model matches the agent's configured model
(
ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID).So choosing a different model for one invocation also drops that agent's variant for that
invocation. This is pre-existing logic, unchanged here, but the new parameter makes it reachable
on purpose rather than only by misconfiguration.
Base branch and background/foreground note
Based on
dev, notv1.18.22-dca. That branch does not exist onoriginor locally(
git ls-remote --heads originlists onlyanalysis-and-understanding,dev,feature/copy-command-with-options,fix/subagent-effective-deny-inheritance), so the statedfallback applies.
Two things follow that a reviewer should know:
devhas no native background-task path. On this basetaskis foreground only:executealways awaitsops.promptinsideEffect.acquireUseRelease, there is nobackgroundparameter, and there is no
BackgroundJobinvolvement. So the new parameter has exactly onebehaviour here, and no foreground/background asymmetry is possible.
The deployed line is materially different and will need a port. The deployed binary is built
from v1.18.22 + the Add
--permission-prompt-toolto match claude code format anomalyco/opencode#75 fix;origin/fix/subagent-effective-deny-inheritance(version1.18.23, tip = "fix(agent): stop inheriting superseded parent session denies in subagents") isthat lineage, and
devis an ancestor of it, 4,129 commits behind. On that branchsrc/tool/task.tsis 371 lines rather than 175, uses EffectSchemainstead of Zod, and doeshave a
backgroundparameter withBackgroundJob. The design here ports cleanly in principle —model resolution precedes the foreground/background branch — but that has not been verified
on that branch in this PR, and the schema change from
z.string().optional()toSchema.optional(Schema.String)is mechanical but real.