ADR 0027: Configured models and narrowed contexts
- Status: accepted
- Date: 2026-09-26
Context
ADR 26 made the loop a Scheme procedure, and its addendum set the standard
for the core: the behaviours we want should be writable as user code. One of
them is routing: plan on one model, hand the implementation to the model a
rule picks, and escalate to a stronger one if the work diverges, with at
most a switch or two per task. Two things stopped a user writing that:
model-turn could only ask the one control model, and the window was always
Go's view of the whole log, so any model asked got the whole conversation.
Decision
Configured models. config.toml gains a registry, [models.NAME]:
provider (ollama, or openai for any chat-completions server), URL, model
id, context length and, optionally, the keychain class of an API key.
(model-turn "NAME") asks that model; (model-turn) still asks the control
model. Its tool calls run like any model's: the core saw the model make
them. Only the user configures models, in their own config: a model is a
destination, so neither a project nor the model can add one.
A keyed model's key is injected per request, and only if the class is bound
to the model's host for the method at header:Authorization:Bearer. The
check runs on every request, so a binding change applies at once, and it is
logged as egress.check. The key never enters Scheme, the provider or the
log.
Narrowed contexts, after Emacs's narrowing. (narrow-context system)
records context.narrow: from there until (widen) (context.widen) or the
user's next message, the window is only what follows the narrowing, under
the strategy's system prompt. The strategy hands over the task with
add-message. After widening, models see the whole conversation again,
including what happened while it was narrowed.
Replay. Narrowing is a log event, so window recipe v4 rebuilds it. An
llm.req sent to a configured model records the model's name and its
context length and keep-alive, which differ from the session's recipe; with
them nodwyn replay --window proves every request whichever model it went to.
The adapter for openai is not streamed: output is shown only once
whole anyway (ADR 25).
Consequences
- Routing is user code (
internal/core/userland_test.go,TestUserlandRouteImplementation): policy, the number of switches and what each model sees are the strategy's, and the core adds no routing policy of its own. - Configuring a model is consenting that what a strategy sends it leaves the machine. ADR 19 said the only content leaving in v0 was Jev's; that is no longer so once a remote model is configured. Narrowing is what keeps that to a task and a plan.
[control]was still Ollama-only, so a user with only a keyed provider could not make it the default model. Amended below.
Amendment: folded contexts and sub-agents
A narrowed context could only end by widening, which shows the model everything that happened inside it, and could not open while the model's calls were unanswered. So a sub-agent's work leaked into its caller's context, and a tool the model calls could not be answered by one.
- Narrowed contexts nest.
(fold-context)ends the innermost one and folds it away: later windows skip everything since itsnarrow-context, and see the enclosing context again. The log keeps the span, logged ascontext.fold; window recipe v5 rebuilds it (a log without the event builds as under v4). narrow-contextno longer waits for the latest model-turn's calls to be answered: they wait for the context to fold, and the context's own model-turns start with none pending.(answer-call call text)answers one of them with the strategy's text, counted against the tool-call budget.widenis refused while a context holds calls waiting for an answer.- A turn that ends with such a context open folds it and answers its waiting calls as not executed, so a call is always followed by its result in every later window.
A sub-agent (sub-agent) and a tool the model calls that a sub-agent
answers (define-agent-tool, dispatched by run-calls) are user code over
these primitives, in internal/loop/agents.scm (docs/scheme.md §9.3).
Amendment: the control model is a configured model (2026-09-27)
[control] has the registry's shape and is built by the same code: it may
be a keyed chat-completions server whose key is bound, and it is the entry
control, which (model-turn) asks; no [models.NAME] may take that
name, and the loop has no special case for it. There is no built-in
default: the default had been one user's own server, unreachable for
anyone else. A session opens without [control], so logs can still be
replayed and Scheme evaluated, and its first model request fails with the
table to add. That failure, like a key that is missing or not bound, is a
permanent error the request retry does not repeat.
Amendment: the planner and the control model — 2026-09-27
The plan gate is user code (plan.scm) and asks the control model like any
other model turn, in a narrowed context that folds away, offered no tools;
its requests are recorded and replay (window-v7 for the tool-less request).
[control] is a configured model like [models.NAME], registered as
"control", with no built-in default.
Amendment: lasting narrowings and compaction on request (2026-09-30)
A narrowing ended at the user's next message, and compaction (ADR 32) is a narrowing, so a compaction lasted only for the rest of its turn: the next message brought the whole conversation back, trimmed at the high water, and the handover note was thrown away. In a long interactive session the note was written again each time the conversation grew past the threshold, and a user who asked for a compaction between turns would have had it undone by their very next message.
narrow-contexttakes an options alist after the agent name;'((lasting . #t))makes a narrowing outlast its turn. If it is still open at the user's next message, the conversation starts there from then on, under the session's system prompt with the new task and plan. A fold or widen in its own turn ends it like any other. It is recorded ascontext.narrowlasting, and window recipe v11 rebuilds it; a log without the field builds as before.- Compaction narrows lasting, labelled
compaction, so later turns go on from the note. The log keeps what came before, for replay, audit and the optimizer. - The user compacts whenever they like with a
/compactmessage, optionally followed by what the note must keep. Between turns it hands over the user's latest request before it, read with a new primitive,(session-requests), and ends the turn without asking the model to go on. A strategy asks for a compaction at its next round with(compact-next-round). Both are user code incompact.scm(docs/scheme.md §9.2).
Amendment: models that take images (2026-10-01)
An attached image (ADR 22, amended 2026-10-01) is sent only to a model that
takes images: ollama says so (/api/show lists vision), and a
chat-completions model says so in its table, vision = true, since those
servers do not. It is attached only if the control model takes images, and
it goes only to the host its confirm named, the control model's: a request
to any other configured model, at another host, carries a line saying the
image is not for it. Routing is still user code; the image's consent is not
stretched by it. A narrowed context, the planner's or a sub-agent's, starts
after the attachment and so carries no image.
Amendment: a server holds a provider's address and key (2026-10-03)
A model's table carried the provider, the URL and the key's class along with the model's id, so the models one provider offers repeated those three in every table, and a new key or address meant finding each of them. In the app, choosing another model on the same server meant typing its address again and its id from memory.
config.tomlgains[servers.NAME]:provider,urlandcredential. A model,[control]or[models.NAME], names one withserver = "NAME"and takes those three from it; one that also sets any of them, or names a server with no table, is refused. Loading copies the three into the model, so the loop, the egress check, the log and replay read a model as they did, and a request records where it went. A model may still give the three itself.- A server is a destination like a model: only the user configures one, in their own config. Its key is injected only where it is bound, checked on every request, as a model's was.
- The app saves servers.
settings.control.writetakes a configured server's name with the model, or a provider and an address: then the main model names the serverconfig.tomlhas at that address (read as the file is now, so a server added by hand is found and never written over), or a new one named after its host. A key given with it becomes the server's credential, so every model on the server sends it.models.offeredlists the models a configured server offers, asked without a key asmodels.statusasks, so the console suggests the model's id; a server that lists its models only to a key says so, and the id is typed. (Since ADR 41, amendment 2026-10-05, it is asked with the server's key, which is bound forGETas well asPOST.) - Not built: adding and removing
[models.NAME]tables and servers from the app. They are written inconfig.tomlby hand.
Amendment 2026-10-09: labels, and the task's model chooses its helper
A sub-agent always asked the control model, and nothing told a strategy or the model which configured models suit which work. Names are the user's to change, so choosing by name does not last.
- A model,
[control]or[models.NAME], may carrylabels = [...]: lower-case words ([a-z0-9][a-z0-9_-]*, no repeats). They are found as efforts are (Config.LabelsOf): a model's own, else those of another entry for the same model.[control]chosen from the list keeps no copy (Model.Choice), since a copy would go stale when the entry's labels are edited and win over them: the default goes by its entry's.(model-info)reports them and(model-names label)lists the models that carry one, so user code routes by label. Settings › Models edits them (settings.model.writelabels), and a label edited reaches the tasks already open (Session.SetLabels): labels change what a model is called by, not where a request goes or which tools are offered, so the prompt cache and the frozen tool list (ADR 9) are untouched. (sub-agent system task tools [name [options]])takes'((model . "NAME"))and asks that model, wrap-up included. An error on the way folds the sub-agent's context before it is raised again, unchanged, so a caller that catches it goes on in its own context.- Exposure is per task, and the default is none. A task opens with the
[models.NAME]its model may hand work to (session.opendelegates,core.Options.Delegates, recorded assession.delegatesand restored on resume for the modelsconfig.tomlstill has). With none, thedelegatetool does not exist and the task works with its own model alone. With some,delegateis offered: its description is fixed with the tool list, so it lists nothing; a call with an empty task answers with the exposed models, their context lengths and their labels as they are then, which is how an edited label shows mid-task. The model chooses by labels. A name outside the exposed set is refused whatever the model asks for. The helper is offered every tool butdelegate((tool-names), hidden), so it cannot hand the work on. A helper that fails, its provider down, out of quota or refusing the request, answers the call with its error: a helper is on another provider, and its failure should not end the task's turn.(delegate-models)(hidden) gives user code the same set. An earlier draft gated eligibility on asubagentlabel; per-task consent replaced it, since a label says what a model is for, not that every task may send it work. - Who chooses: the person, on this computer. Labels are written only in the
user's
config.tomland Settings, never by a project's files, anddelegatesis refused from a paired device as a model choice is (ADR 30). Exposing a model is the consent that the task, and what its helper reads on the way, may reach that model's provider, as configuring one already was for a strategy. The console says so where the choice is made. - Not built: changing a task's exposure after it opened (the tool list is
fixed for the session, so a task that opened with none cannot gain the tool;
one that opened with some could change the set, which nothing asks for yet),
offering the default model in the console as a helper of a task on another
model (the session takes its
[models.NAME], but the console's list shows the default ascontrol, which in a task names the task's own model), the TUI, line mode andnodwyn runchoosing delegates, and descriptions beyond labels.
Amendment 2026-10-27: the helpers ticked last are the next task's
The console's New task window no longer starts every task with no helpers:
the helpers ticked for a task are kept by the daemon and ticked for the
next one on that host, with the model and judge chosen there (ADR 41,
amended 2026-10-27), because the person asked for every choice they make
there to be the next task's default. A task with none still works with its
own model alone, and delegates is still refused to a paired device.