The model role, not the model id — and why 'code' earns its own tier
Smith routes refining, implementation, and one-shot helpers through three role-scoped views of one live settings store; the agent code never learns a provider name.
On this page
“Which model does the agent use?” is the wrong singular question for Smith.
The refiner and profile architect conduct long, read-heavy conversations with a human. The forge coder spends its time editing and driving tools unattended. Context digests and memory curation are short one-shot transformations. Those are three workloads with different latency, cost, and capability requirements.
Efferent names them general, code, and fast. The useful implementation detail is not the enum. It is that each workload receives a view of the same settings store, so the router remains completely unaware that roles exist.
Three jobs, no provider names
Smith ships overridable defaults:
export const SMITH_MODEL_DEFAULTS = {
general: "opencode:kimi-k2.6",
code: "opencode:kimi-k2.7-code",
fast: "opencode:deepseek-v4-flash",
} as const
The literals stop at the composition edge. Refiner code asks for LanguageModel; the forge implementor asks for LanguageModel; compaction asks for UtilityLlm. None of them names Kimi, Anthropic, Google, or OpenAI.
The roles mean:
- general — refining a SpecDoc with the human and designing a workspace quality profile;
- code — the persistent Smith implementor conversation, including post-forge follow-up work;
- fast — context digests and other bounded helper transformations where latency matters more than agentic tool use.
The product already has explicit workflow stages, so the stage itself is the routing decision. No model is asked to choose another model id.
A role is a settings-store view
Every provider call already loads settings.model. Instead of teaching the router a role parameter, Efferent wraps the store for the scope that needs another role:
export const roleModelView = (role: "code" | "fast") =>
Layer.effect(
SettingsStore,
Effect.map(SettingsStore, (inner) => ({
load: Effect.map(inner.load, (settings) =>
new EngineSettings({
...settings,
model: Option.orElse(
role === "code" ? settings.codeModel : settings.fastModel,
() => settings.model,
),
}),
),
setRole: inner.setRole,
set: inner.set,
})),
)
To code inside the forge, composition becomes:
LanguageModelLive.pipe(
Layer.provide(roleModelView("code")),
)
Everything below still believes it loaded the ordinary model field. The view substitutes codeModel when present and falls back to general when absent. The router keeps doing exactly one job: parse the selected provider:modelId, resolve current credentials, and build the provider service.
This is a particularly clean use of a Layer. The role does not vary inside the forge implementor; it defines which world that whole subsystem runs in. State inside the store can stay live while the dependency view stays fixed.
Fallback makes roles zero-config
The highlighted Option.orElse is the product behavior. A user who configures only model still gets all three capabilities. Code and fast follow general until the user chooses distinct values.
That lets the architecture name workloads before configuration catches up. Adding a fast helper does not force every existing user to edit JSON, and adding a code role does not make “one model for everything” invalid. The second and third knobs are refinements, not prerequisites.
Smith itself fills its three defaults after applying precedence:
- CLI
--model,--code-model, and--fast-model; - workspace and user
.efferent/config.json; - Smith’s shipped defaults.
The TUI edits the same role fields through :model. There is one vocabulary in flags, files, and UI.
Selection remains live per call
LanguageModelLive loads the role-scoped store for every provider call, so changing :model code during a long forge takes effect on the next model step.
That liveness follows the broader provider-as-state design: login and model changes should not require a restart. It also has a real tradeoff. A single attempt can contain responses from two coding models if the human switches mid-run. The transcript records each assistant message’s model, so the transition is observable, but the run is no longer model-homogeneous.
For an interactive developer tool, immediate control currently wins. A benchmark or reproducible scenario supplies a fixed settings world instead. The important thing is that the boundary is explicit: the product uses a live store; controlled environments replace the store.
Why code deserves a tier
The reason is not “coding models are better,” full stop. Smith’s workflow creates a clean specialization boundary.
The general model negotiates what should exist: goal, acceptance, constraints, checks, and quality profile. Once the human locks that contract, the code model receives a different job: change the workspace until deterministic gates accept it. It gets write tools, a bubblewrap shell, gate feedback, and up to three attempts. The product has already separated deliberation from execution, so routing them independently costs almost no extra conceptual machinery.
The fast tier has an even stronger boundary. A digest call has no tools and one bounded output. Paying agentic-model latency for it buys little, while a weak digest has a contained failure mode: compaction is best-effort and an empty or failed summary leaves the trail unfolded.
Roles are most useful when their error budgets differ. “Write the correct implementation” and “compress this trail without losing the task” should not be priced or failed the same way.
What the indirection costs
A role name hides today’s concrete model from a source reader; you have to inspect resolved settings or the TUI. Provider-specific behavior still leaks where it genuinely matters — subscription OAuth, prompt-cache markers, retry classification — so roles do not erase the edge.
Live selection also means a config edit can change a run halfway through, as noted above. And three defaults are three opinions the project must maintain as providers and model versions move.
Still, the call site says something durable. roleModelView("code") explains why this model is being used. A literal model id only says what happened to be preferred when the line was written.
That is the practical rule: hardcode a model only when the model itself is the requirement. When the requirement is a job — refine, implement, summarize — name the job, resolve it at the edge, and let configuration own today’s answer.