Carries two edits beyond conflict resolution, both forced by what master
brought in:
- `CustomProviderCard`: master added front-end key validation and a
component-level `keyValue` (already trimmed) while still writing
`apiKeyEnv` unconditionally. Kept this branch's blank-key rule and its
committed-profile retry gate, and adopted master's single `keyValue` so
the component has one spelling of the key rather than two.
- `docs/user/guide/providers`: master merged #1810, whose default-model
section still taught overriding the `api-gateway` row in
`$DSH_HOME/config.yaml` — the behavior this branch replaced. Rewritten
for the settings section the picker now writes, plus the review fix from
#1810 replacing the colloquial 挂着 in the opener.
A default naming a route the Models page has since removed left the
composer saying 选择模型 while the input still accepted a message, which
then failed inside the adapter mid-turn.
`session.prompt` now refuses with `model-unavailable` before opening a
turn. That is the enforcement boundary: the method stays callable no
matter what a client disables. `session.models` reports the same fact as
`routable`, and ui-model pushes a block through the new
`ctx.conversation.blocks` registry so the bar renders the disabled
textarea it already renders without a workspace, carrying the blocker's
own reason. The push direction is forced — ui-model already depends on
ui-conversation, so ui-conversation cannot read it back.
The gate is `routable`, not "matches no advertised group": catalog
membership is advisory, so a route serving a model it stopped advertising
is missing from the groups yet perfectly usable, and `null` before the
first load never blocks so a slow Host cannot lock a working composer.
The scaffold gains a route-only adapter for fixture-less keyless
scenarios. Registering zero providers is a test artifact — every product
composition mounts one — and the goldens that froze the seat's fallback
label now show the model those scenarios actually route to.
The Web picker collapsed onto the directory flow (see the one-route-to-add-a-workspace
Agent Note), leaving workspace.create({ name }) with no product consumer. Delete the
whole feed line: the wire schema's name member and WorkspaceApi spelling, the gateway's
workspaceRoot config/default and the mkdir branch, the client seam that carried the name
(WorkspaceCreateInput, WorkspacesService.create, intentName), the dsh web
--workspace-root flag, and the fixture's name handling. workspace-name-conflict stays as
workspace.rename's duplicate-title error.
A row's stored profile could not tell a hand-declared gateway from a
shipped provider whose models someone narrowed — both look identical from
outside the adapter — so the Models page had no way to mark the routes a
deployment added itself.
The directory entry now carries `declared`, answered by the owning adapter
against its own installed catalog, and the page renders a Custom tag from
it. Absence stays "this adapter draws no such distinction" rather than
"shipped", so a route no adapter claims is labelled neither way.
Also records the default-route work's Agent Note and the e2e evidence for
all three changes: the composer switch writing the section, and the Models
page declaring a route with its own reasoning effort.
`ui-question`'s node half called `ctx.tools.register` on the host context.
`ScopedLayers.merge()` combines the global layer with the agent's exact-scope
layer, and an unscoped registration lands in the global one — so the tool
reached every agent no matter which preset composed it. `core-web`, sold as a
two-tool benchmark surface, really presented three.
Rendering a question is a host UI capability; having the tool is an agent
capability, and only a preset decides that. The node half is now empty and the
`tool-ask-user` row moved into the preset that wants it. The TUI keeps its own
row, having no presets.
The composition tests now assert the global tool layer is EMPTY, which is the
invariant that would have caught this: any tool outside a preset reaches every
agent. The browser lane's composition, seeded-history, and hermetic-skill
assertions address their registries through a composed agent for the same
reason — those services are per session now, and the host cannot resolve an
`isolate` realm by name.
The shipped host composition moved: `base.cordis.yml` and `web.cordis.yml`
are the dsh-base and dsh-web-app patch layers now. The gate still opened the
old paths and crashed on ENOENT — a gate that cannot read its inputs proves
nothing, loudly or otherwise.
Retargeting it also surfaced what the move implies for ownership: the web
bundle carries the roster and its browser plugin rows now, so the bundle's own
manifest is what must declare them. The gate's existing bare-plugin check said
so as soon as it could parse the file again.
Code Mode was a deployment-wide field on the host `tools` row: a
deployment ran every session that way or none. The obvious product
shape — 代码模式 beside 标准/极简/创造 in the preset picker — had
nothing to hang on.
The registry itself cannot move into a preset; the agent loop's
scheduler, the api-proxy's presenters, and every tool plugin are its
consumers. So split the registry from its projection: `presentAs(mode)`
writes one cell on the calling agent's scope layer, exactly as
`restrict()` does, and the three reads that decided presentation take
that scope's mode instead of the service's. The config `mode` becomes
the default agents shadow rather than a process-wide fact.
Two consequences are load-bearing. `run_code` now enters a view only
for scopes whose own mode presents it — a native agent must not find it
dispatchable because another agent in the process does — and the
reserved name holds whatever the configured mode, since any agent may
select a code mode later.
`dsh-agent-tool-mode` is the row a preset carries to declare this. A
code mode waits for the host's `codeRuntime` rather than assuming it,
so a runtime-less deployment fails the preset at mount, naming the
row, instead of at the session's first request.
The shipped `code` preset is `standard` plus that row, ordered second.