fix(headless): dsh run is a direct core front door

This commit is contained in:
Tianyi Cui
2026-08-09 12:13:58 +08:00
parent 772c580ee1
commit 9d5eb37638
159 changed files with 1508 additions and 1042 deletions
@@ -6,38 +6,42 @@ English | [中文](2026-08-07-default-model-follows-the-picker.zh.md)
## Problem
The route a new session started from was frozen into the gateway's composition entry (`api-gateway` in the web-app bundle patch). Switching models in a conversation reached that conversation only: the next session went back to the shipped default, and the only way to change it was to hand-edit a `cordis.yml` row and restart. There was no user-settings tier between the composition and the per-session choice.
A session model picker and a deployment default are two layers of the same preference. If the picker affects only its addressed session, the next blank session can select a different model with no user-facing way to align the default. If the default lives inside a Host gateway, direct Agent front doors cannot share it without depending on Host or duplicating state.
Reasoning effort makes the persistence shape significant: a model selection without an effort must clear a stored effort, or the next Agent may apply an effort that its selected model does not accept.
## Decision
`ApiProxyService` registers its `{provider, model, reasoningEffort?}` slice as the `api-gateway` settings section: the composition entry is the `base` layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` stays outside the section — a launcher fact, not a preference. `reasoningEffort` is the mirror case: it lives in the section but NOT in the plugin config, because the seam merges the user layer over the composition entry per field and an absent key cannot override a present one. A composition-set effort would therefore survive every later switch to a model without one — precisely the stranding the wholesale `replace` exists to prevent. Effort is a per-model fact anyway; a deployment default for it belongs on the adapter profile, which resolves per model.
`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is front-door-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core front door](../architecture/2026-08-09-headless-direct-core-front-door.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state.
`session.selectModel` records an accepted switch as the new default. There is no separate gesture: switching models in the composer IS how the default is chosen. The write is `replace`, not `update` — switching to a model with no reasoning effort has to clear a stored one, and a merged patch would strand it for the next session to fail on. A storage failure is logged without undoing the switch, which already applies to its own session, and a deployment with no settings provider keeps the entry with the switch staying process-local.
`reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model.
`ApiProxyDefaults` carries `defaultTarget()` and `persistDefaultTarget()` closures instead of flat `provider`/`model` fields, so `createApiProxy` needs no knowledge of the settings seam.
`session.selectModel` applies an accepted `ModelSelection` to its session and calls `saveDefaultModelSelection()` for the shared Agent default. A storage failure is logged without undoing the session selection. A deployment with no settings provider retains the composition entry and keeps the accepted selection only in that session.
`targetFor` resolves its tiers on **every** read rather than seeding a ref once: an explicit selection in this process, else the session's own latest logged `request/header`, else the live default. Both directions depend on the re-read. A session that has run a turn derives from its log forever after, so changing the default never retargets it. A session still blank starts from a default saved after it was created — which matters because New Session reuses a blank session rather than minting another, so a creation-time seed would show the superseded model in exactly the flow the feature exists for.
`ApiProxyDefaults` carries `defaultModelSelection()` and `saveDefaultModelSelection()` closures, so `createApiProxy` has no dependency on the Settings seam. `ApiProxyService` wires them to `ctx.agentDefaultModel.currentSelection()` and `ctx.agentDefaultModel.saveSelection()`.
The stored route is not validated against the registry. A default naming a route the Models page has since removed still reaches `session.models` as `current`, matching no advertised group — which is what makes the composer seat's existing fallback prompt for a selection instead of naming a model the deployment cannot reach.
`selectionFor(agent)` resolves its tiers on every read: a process-local session selection, otherwise the session's latest logged `request/header`, otherwise the live Agent default. A session with a logged request remains bound to that durable selection. A blank session observes the current default even when it was created before the preference was saved; this matches the New Session surface, which may reuse a blank session.
The stored selection does not require catalog membership. A provider route may serve a model omitted from its advisory catalog. `session.models` therefore reports the stored selection independently of advertised groups and separately reports whether an adapter serves its provider.
## Consequences
`ApiProxyDefaults` changed shape, updating ~40 test construction sites. `host.describe` now reports the live default rather than a captured one, which is what it always meant. `settings.yaml` gains an `api-gateway:` section the moment a user switches models; the `api-gateway` namespace is deliberately NOT added to the gateway's exposed-namespace allowlist, so the Settings page neither reads nor writes it — the model picker is its editor.
`host.describe` reports the live Agent default. A successful model switch stores an `agent-default-model:` section in `settings.yaml`. The gateway does not expose that namespace through its Settings-page allowlist; the model picker is its editor.
## Follow-up: blocking a session that cannot send
## A session that cannot send
A default naming a route the Models page has since removed leaves the composer saying "Select model" while the input still accepts a message, which then fails inside the adapter mid-turn. Two changes close it.
`session.prompt` refuses with `model-unavailable` before opening a turn when no adapter serves the session's selected provider. This method is the enforcement boundary; a disabled composer is only a client affordance.
The Host refuses. `session.prompt` checks whether an adapter serves the session's route and answers `model-unavailable` before opening a turn. This is the enforcement boundary: a client that disables its composer is an affordance, and the method stays callable regardless.
`session.models` reports `routable`. The ui-model plugin projects an unroutable selection through `ctx.conversation.blocks`, and the composer becomes inert while leaving the model seat available. An unknown client-side routability state, including an initial or failed catalog load, does not block input.
The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder — except the model seat, which a block deliberately leaves live, because choosing a model is how the user clears it. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back.
The gate is `routable`, NOT "the current target matches no advertised group". Catalog membership is advisory by design: a route serving a model it stopped advertising is absent from the groups yet perfectly usable, and blocking there would break a supported configuration (a narrowed `models` list over a live route). `routable` is also three-valued on the client — `null` before the first load or after a failed one never blocks, so a slow or unreachable Host cannot lock a working composer.
Routability is distinct from catalog membership. A live provider route can serve an unadvertised model, so absence from catalog groups does not imply that the session is unusable.
## Alternatives considered
- **Falling back to the composition entry when the stored route is unregistered.** Rejected: the composer would then name the shipped DeepSeek model instead of prompting, which is both a silent switch to a provider the user did not pick and the opposite of the requested behavior.
- **Validating and clearing a stale default.** Rejected: catalog membership is advisory by design (`buildModelCatalog` documents it), so an adapter may serve a model its own catalog stopped advertising; self-healing would break that deliberate case.
- **A `settings.update` merge patch.** Rejected: it cannot clear `reasoningEffort`, so a switch from a reasoning model to a plain one leaves an effort the next session fails on.
- **Persisting only from blank sessions.** Rejected: the most informative switch is the one made mid-conversation after seeing a model underperform, and that one would never be saved.
- **A separate "set as default" affordance.** Rejected for now: it adds a second gesture for what every comparable product infers from the switch itself. The cost is that a temporary switch in an old session also moves the default.
| Alternative | Contract mismatch |
|---|---|
| Fall back to the composition entry when the stored provider is unavailable | The product silently switches away from the user's selection. |
| Validate the stored selection against catalog membership | Catalogs are advisory and may omit requestable models. |
| Save with a merge patch | An omitted `reasoningEffort` cannot clear the stored field. |
| Save only selections made in blank sessions | An informed mid-conversation selection never becomes the deployment default. |
| Add a separate “set as default” gesture | The session picker and future-session preference can diverge despite representing the same user choice. |