Skip to content

feat(task): optional per-invocation model selector for sub-agent tasks - #3

Closed
leoncheng57 wants to merge 2 commits into
devfrom
feat/task-model-selection
Closed

feat(task): optional per-invocation model selector for sub-agent tasks#3
leoncheng57 wants to merge 2 commits into
devfrom
feat/task-model-selection

Conversation

@leoncheng57

Copy link
Copy Markdown
Owner

Summary

Adds an optional per-invocation model parameter to the native task tool, so a delegating
agent can choose the model for an individual sub-agent child instead of always inheriting.

model: z.string().describe(/* "provider/model", optional, omit to keep default behaviour */).optional()

Precedence is now:

  1. explicit per-invocation model (new)
  2. the subagent's configured model (agent.<name>.model)
  3. the invoking assistant's model

Omitting the parameter preserves today's behaviour exactly — the diff to the resolution
expression is a single added branch:

const model = params.model
  ? Provider.parseModel(params.model)
  : (next.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID })

The string is parsed with Provider.parseModel, which splits on the first / and rejoins the
rest, 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 running
and result metadata, so downstream consumers are unchanged.

On a task_id resume the explicit model applies to that invocation only. It is not written to the
child session as a default, and a later invocation of the same task_id without model falls back
to 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 app
exposes 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, the
consuming 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.parseModel is a pure string
split that cannot fail; existence is validated downstream by SessionPrompt.getModel, which is the
same point at which an invalid agent-configured model fails today.

Rationale:

  • It is the smallest correct change (+21 lines of source) and keeps Provider service state out of
    the tool.
  • Failing early would make explicit-model failures behave differently from an invalid
    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:

  • part status error, message contains ProviderModelNotFoundError
  • tool.state.metadata is undefined
  • sessions.children(parent) has length 1

That last point is a genuine asymmetry worth recording: the handleSubtask path does preserve
metadata 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)

  • Not threading SubtaskPart.model (slash-command model) into this parameter — that would change
    established command/agent precedence. handleSubtask still builds its own taskArgs and does
    not pass model.
  • No model permission system.
  • No OpenAPI/SDK change. Verified as not required: nothing under sdks/, specs/, or
    packages/sdk references the task tool's parameter names, and the experimental tool-schema
    endpoint 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 running opencode serve was touched.

Target test file:

$ bun test --timeout 30000 test/tool/task.test.ts
 13 pass
 0 fail
 50 expect() calls
Ran 13 tests across 1 file. [2.82s]

Both changed test files:

$ bun test --timeout 30000 test/tool/task.test.ts test/session/prompt-effect.test.ts
 50 pass
 0 fail
 186 expect() calls
Ran 50 tests across 2 files. [29.90s]

Full packages/opencode suite:

$ bun test --timeout 30000
 1952 pass
 11 skip
 1 todo
 0 fail
 9095 expect() calls
Ran 1964 tests across 160 files. [158.14s]

Typecheck and lint:

$ bun run typecheck        # tsgo --noEmit
(exit 0, no output)

$ bun run lint             # oxlint, repo-wide
Found 2418 warnings and 0 errors.   # all pre-existing, none in changed files

$ bunx prettier --check <changed files>
All matched files use Prettier code style!

New tests

test/tool/task.test.ts (+7):

  • explicit model overrides the invoking assistant's model
  • explicit model overrides the subagent's configured model (and without it, the agent's model still wins)
  • omitting the model preserves the existing fallback chain
  • explicit model on a resumed task_id reaches the existing child, creates no second child, and does not persist as that child's default
  • running and result metadata report the selected { providerID, modelID }
  • openrouter/anthropic/claude-sonnet-4.5 keeps the slash in the model id
  • an unknown model is not validated by the tool; it is passed through and the child is still created

test/session/prompt-effect.test.ts (+2):

  • an explicit unknown model fails end-to-end at the provider lookup (ProviderModelNotFoundError),
    the child never reaches the LLM (llm.calls === 2), the error part carries no metadata, and the
    orphaned child exists
  • an explicit model overrides an agent pinned to a missing model, so the child runs to completion
    and reports the explicit model in its metadata

Risks

  • Cost. A delegating agent can now select any configured model, including an expensive one, on
    its own initiative. There is no model permission or allow-list gate in this PR; the existing
    task permission still gates whether a subagent may be launched, not on what model. The
    parameter description tells the model to use it sparingly, but that is guidance, not enforcement.
  • Overriding an intentional pin. If an agent was deliberately configured with a specific model,
    an explicit model overrides that pin. The parameter description says so, but a caller can still
    do it. Operators who need a hard pin should treat this as a reason to keep such agents behind a
    task deny rather than relying on the configured model alone.
  • Variant inheritance. SessionPrompt.createUserMessage only carries the agent's variant
    through 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.
  • Late validation as described above: an orphaned child plus a metadata-less error part.

Base branch and background/foreground note

Based on dev, not v1.18.22-dca. That branch does not exist on origin or locally
(git ls-remote --heads origin lists only analysis-and-understanding, dev,
feature/copy-command-with-options, fix/subagent-effective-deny-inheritance), so the stated
fallback applies.

Two things follow that a reviewer should know:

  1. dev has no native background-task path. On this base task is foreground only:
    execute always awaits ops.prompt inside Effect.acquireUseRelease, there is no background
    parameter, and there is no BackgroundJob involvement. So the new parameter has exactly one
    behaviour here, and no foreground/background asymmetry is possible.

  2. The deployed line is materially different and will need a port. The deployed binary is built
    from v1.18.22 + the Add --permission-prompt-tool to match claude code format anomalyco/opencode#75 fix; origin/fix/subagent-effective-deny-inheritance (version
    1.18.23, tip = "fix(agent): stop inheriting superseded parent session denies in subagents") is
    that lineage, and dev is an ancestor of it, 4,129 commits behind. On that branch
    src/tool/task.ts is 371 lines rather than 175, uses Effect Schema instead of Zod, and does
    have a background parameter with BackgroundJob. 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() to
    Schema.optional(Schema.String) is mechanical but real.

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.
@leoncheng57

Copy link
Copy Markdown
Owner Author

Closing as superseded by #4.

This branch targeted the fork's dev, which is ~4,145 commits behind anomalyco/opencode:dev (fork dev is at 1.4.7, dated 2026-04-16). Nothing built from it can reach the deployed binary, so it was only ever a reference implementation.

#4 re-implements the same capability on fix/subagent-effective-deny-inheritance (1.18.23), the branch the pinned binary is actually built from, in that branch's Effect Schema.Struct idiom, with early validation and explicit background-launch coverage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant