An agent step's model field is not rendered against the step's scope, but nothing rejects a template there either. validate passes, the plan runs, and the failure arrives at the agent step itself — with the raw template text quoted back as if it were a model name.
Repro
- id: E6
tool_name: map
input:
over: '{{E5b.items}}'
concurrency: 6
do:
tool_name: agent
input:
prompt: '... {{item.name}} ...' # renders
model: '{{input.scout_model}}' # does NOT render
tools: [user__repo_grep, user__repo_file]
max_iterations: 18
output_schema: { ... }
graph plan validate → ok: true. The run then reaches E6 and dies:
Error: plan failed at step E6 (map): `do` item 0 (agent): LLM call failed:
no model named '{{input.scout_model}}' is configured;
available names: aggregator, opus, sonnet, default, chat, planner, solver, repair, embedder, judge
Why this is worth fixing
The asymmetry is the trap. In a plan, builtin__infer's model is an ordinary tool input and renders like everything else — model: '{{input.model}}' works, and it is exactly how you parameterize a model per call. The agent control step's model looks identical in YAML and silently doesn't. I hit this authoring a review plan that fans a rubric out across several models: the infer step took {{input.model}} fine, so I wrote the same thing on the scout's agent step and had no reason to think it differed.
The failure is deferred past the expensive part. model is a static value known at load time — there is nothing to defer. But because it is only consulted at agent.rs:412, the plan runs its whole prefix first. In my case that was a changed-file listing, seven parallel file fetches, a grouping inference, and a filter — about two minutes and a real inference call — before the step that could never have worked was reached. Every retry pays that again.
The error blames configuration. no model named '{{input.scout_model}}' is configured; available names: … reads as "you forgot a [models.named] entry", which sends you to config.toml instead of to the plan. The braces are the whole clue and they are easy to read past in a long name list.
Current behavior, for reference
This is consistent and deliberate as implemented — only prompt and systemPrompt render:
crates/graph-core/src/pipeline/agent.rs:29-30 — "prompt and systemPrompt render against the step's scope when the loop starts, not at parse time."
crates/graph-core/src/pipeline/agent.rs:373-377 — those two are the only fields passed through render.
crates/graph-core/src/pipeline/agent.rs:412 — spec.model.as_deref().unwrap_or("chat"), handed straight to chat_named.
docs/plans/agent-step.mdx:42-47 matches: the prompt and system_prompt rows say they render, and the model row says nothing either way. So the docs are accurate by omission — a reader who already suspects the difference can confirm it. The gap is that nothing tells a reader who doesn't suspect it, at the moment they get it wrong.
Two ways to close it
Either render it. model resolves against the same scope as prompt, so {{input.scout_model}} and {{item.model}} work and the field stops being a special case. This is the one that makes plan-level model fan-out expressible for agent steps at all — worth noting map-over-models with a per-item agent model has no workaround today short of duplicating the step.
Or reject it at validation. The whole input is already checked at load time (agent-step.mdx: unknown fields, non-string prompt, bad output_schema, max_iterations under 1, empty tools, template references that point forward or at nothing). Add: a model containing {{ fails validation with something like step E6: `model` is a static field and does not render templates — use a literal model or role name, or set the model on the `chat` role in config. Then the plan never reaches a run it cannot finish.
Rendering is the better outcome if the static-ness isn't load-bearing elsewhere; validation is the smaller change and still removes the deferred failure and the misleading error. Either way the docs row for model should say which it is, the way the prompt and system_prompt rows do.
Found while building a multi-model PR reviewer in another repo; happy to take whichever fix you prefer.
An
agentstep'smodelfield is not rendered against the step's scope, but nothing rejects a template there either.validatepasses, the plan runs, and the failure arrives at the agent step itself — with the raw template text quoted back as if it were a model name.Repro
graph plan validate→ok: true. The run then reaches E6 and dies:Why this is worth fixing
The asymmetry is the trap. In a plan,
builtin__infer'smodelis an ordinary tool input and renders like everything else —model: '{{input.model}}'works, and it is exactly how you parameterize a model per call. Theagentcontrol step'smodellooks identical in YAML and silently doesn't. I hit this authoring a review plan that fans a rubric out across several models: theinferstep took{{input.model}}fine, so I wrote the same thing on the scout'sagentstep and had no reason to think it differed.The failure is deferred past the expensive part.
modelis a static value known at load time — there is nothing to defer. But because it is only consulted atagent.rs:412, the plan runs its whole prefix first. In my case that was a changed-file listing, seven parallel file fetches, a grouping inference, and a filter — about two minutes and a real inference call — before the step that could never have worked was reached. Every retry pays that again.The error blames configuration.
no model named '{{input.scout_model}}' is configured; available names: …reads as "you forgot a[models.named]entry", which sends you toconfig.tomlinstead of to the plan. The braces are the whole clue and they are easy to read past in a long name list.Current behavior, for reference
This is consistent and deliberate as implemented — only
promptandsystemPromptrender:crates/graph-core/src/pipeline/agent.rs:29-30— "promptandsystemPromptrender against the step's scope when the loop starts, not at parse time."crates/graph-core/src/pipeline/agent.rs:373-377— those two are the only fields passed throughrender.crates/graph-core/src/pipeline/agent.rs:412—spec.model.as_deref().unwrap_or("chat"), handed straight tochat_named.docs/plans/agent-step.mdx:42-47matches: thepromptandsystem_promptrows say they render, and themodelrow says nothing either way. So the docs are accurate by omission — a reader who already suspects the difference can confirm it. The gap is that nothing tells a reader who doesn't suspect it, at the moment they get it wrong.Two ways to close it
Either render it.
modelresolves against the same scope asprompt, so{{input.scout_model}}and{{item.model}}work and the field stops being a special case. This is the one that makes plan-level model fan-out expressible for agent steps at all — worth notingmap-over-models with a per-item agent model has no workaround today short of duplicating the step.Or reject it at validation. The whole input is already checked at load time (
agent-step.mdx: unknown fields, non-string prompt, badoutput_schema,max_iterationsunder 1, emptytools, template references that point forward or at nothing). Add: amodelcontaining{{fails validation with something likestep E6: `model` is a static field and does not render templates — use a literal model or role name, or set the model on the `chat` role in config. Then the plan never reaches a run it cannot finish.Rendering is the better outcome if the static-ness isn't load-bearing elsewhere; validation is the smaller change and still removes the deferred failure and the misleading error. Either way the docs row for
modelshould say which it is, the way thepromptandsystem_promptrows do.Found while building a multi-model PR reviewer in another repo; happy to take whichever fix you prefer.