Files
deepseek-harness/packages/host/apiproxy
Yichen Jiang 3584d8e088 docs(skill): document the user-explicit invocation path
Bilingual README updates for the four touched packages (ui-skill's claim
flow and deterministic-injection model experience, the apiproxy skills
domain, the shared renderSkillContent seam export, the catalog stitch
sentence), the implemented Agent Note triplet recording the decision and
its peer-product evidence, and the regenerated catalogs/graphs.
2026-08-08 01:52:43 +08:00
..

@deepseek-ai/dsh-host-apiproxy

English | 中文

The API gateway every client shape shares: the TS contract (src/api/, zero Node dependencies, importable from the browser), the fetch carrier pair (src/fetch/: toFetchHandler on the host side, AbstractApiClient plus platform subclasses on the client side), and the host-side implementation (src/api-proxy.ts: createApiProxy plus the default-exported ApiProxyService gateway plugin — config {provider, model, reasoningEffort?, workspaceRoot?}, provides ctx.apiProxy). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap ctx.apiProxy themselves. The shipped core composition lives in packages/bundle/base/cordis.patch.yml.

The default route (api-gateway settings section)

{provider, model, reasoningEffort?} is also the gateway's user-settings section, registered under api-gateway: the composition entry is the base layer and settings.yaml layers the user's own choice over it. workspaceRoot is deliberately outside the section — a launcher fact, not a preference.

A session resolves its route from three tiers, re-read on every access rather than seeded once: a selection made in this process, else the session's own latest logged request/header, else this default. Re-reading is what makes both directions hold — a session that has run a turn derives its route from its log forever after, so changing the default never retargets it, while a session still blank (New Session reuses one rather than minting another) starts from a default saved after it was created.

session.selectModel records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. What it stores is the RESOLVED target, so an adapter-materialized default effort is pinned as the user saw it and a later adapter-default change does not silently move stored defaults. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local.

The section's reasoningEffort has no counterpart in the plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model.

The stored route is not validated against the registry, in either direction. A default naming a route the Models page has since removed still reaches session.models as the session's current — matching no advertised group, which is precisely what makes a selector prompt for a replacement instead of naming a model the deployment cannot reach. Repairing it silently would also break the deliberate converse: an adapter may serve a model its catalog does not advertise.

Contract layer (/api)

Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: ClientRequest (POST /api/<method> body), ServerResponse (that POST's response body), ServerRequest (SSE frame), ClientResponse (POST /api/respond body). Responses always echo the matching request's rpcId and never mint a new one. Method parameter/return structures live only in the domain interface signatures (SessionsApi, HostApi, EventsApi); RpcMethodMap registers the methods and every other position derives via RequestPayload<K>/ResponseValue<K>. Zod schemas anchor satisfies z.ZodType<Wire<T>> and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride RpcResult's error branch (RpcErrorDetailsMap closes the code set); HTTP status expresses only the carrier. Every /api POST must declare the application/json media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind.

The layering/protocol decisions are recorded in the GUI layering and RPC protocol RFC; the browser-side consumption architecture in the web client architecture RFC.

Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in selected and non-empty custom text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as bad-response.

session.history reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. maxMessages counts user/message and assistant/message events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.

session.history's tail page (beforeSeq absent) additionally carries an optional projections block — the watermark snapshot of every unit registered on ctx.sessionProjections (@deepseek-ai/dsh-session-projection), with asOfSeq = the last event seq the values reflect (-1 on an empty log). The gateway also subscribes to the registry's change feed and mints a session/projection mux frame per changed unit ({sessionId, key, value, seq} — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep values/value wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.

Session titles ride the generic projection pair like every other domain — the history-tail projections block plus session/projection frames under the title key (the bespoke session/title frame is retired). Titles do not join session.list; cold sessions remain metadata-only there until opening or resuming attaches their logs. session.rename accepts an explicit user title (resuming a cold session first), delegating to ctx.sessionTitle.rename — the accepted session/title event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its title projection cell ahead of the push frame; a title that normalizes to empty returns title-invalid.

session.fork maps an optional event anchor to the first turn/end at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns fork-unavailable rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, workspace-attach-failed carries the already-published child id so clients can reconcile it. The SessionStore fork decision owns the boundary rationale.

Session model routing is a session-domain contract. session.models returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. session.selectModel validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns model-unavailable. session.models additionally reports routable: whether an adapter currently serves the current target's route, which is deliberately NOT derivable from the groups — a route serving a model it stopped advertising is absent from them yet perfectly usable, while a route whose adapter is gone can serve nothing. session.prompt refuses on that same fact with model-unavailable rather than spending the pre-step path to fail inside an adapter; a client that disables its composer is an affordance, and this method stays callable regardless.

Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete next-turn queue from durable agent/inbox/spliced mutations and broadcasts authoritative session/queue snapshots after each change and on reconnect; pending next-step steering stays outside this Web projection. Within next-step, user-origin messages carry the steering placement while injected context (approval notices, task completion, attached snapshots) carries context and is not surfaced until claimed. The message-local agent/inbox/inserted, claimed, and discarded notifications remain available to lifecycle observers but do not build the queue view. session.updateQueue addresses one MessageId; edit and remove mutate the attached Agent through Inbox.splice(). A claim's pure deletion splice wins races before pre-step admission, so a later operation returns queue-item-not-found. session.cancel aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.

Workspace and Session lists are separate reconnect baselines. workspace.create({ name }) creates a uniquely titled directory under the configured root, while workspace.create({ path }) adopts an existing canonical directory and permits basename-derived titles to repeat. workspace.delete removes only the Workspace registration, session.create accepts an optional preallocated Session id, and host/workspace-changed, host/workspace-removed, plus host/session-added carry committed increments in either arrival order. workspace.archiveSession adds one session to the registry-global archive set and answers the full updated set; workspace.list carries that set as the reconnect baseline and host/archived-sessions-changed pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with session-not-found. Registration deletion preserves the directory and session logs; its Sessions remain in session.list and become Ungrouped. SessionSummary.blank and the host/session-added frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first host/session-status(running:true), and treat session.list as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of list().

session.search is a bounded content-search projection over the sessions visible through session.list. The gateway asks the optional ctx.sessionQuery service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.

A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot without discarding the learned provider page size. Limit probes and stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an internal business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a limit or stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an internal business error so clients can retain metadata-only matches.

Directory picking delegates to the composed ctx.directoryPicker backend (the directory-picker seam); a method called outside the composed capability's kind fails with directory-picker-unavailable (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under native, host.pickDirectory opens one native chooser and returns its selected path (null on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under browse, host.listDirectory returns one name-sorted directory level with breadcrumb ancestry, a home anchor, and host-owned hidden flags (absent path = home directory), and host.createDirectory creates one validated child segment; the backend's typed failures map 1:1 onto the directory-unreadable/directory-exists/directory-create-failed codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other /api request.

host.openPath opens a filesystem path with the operating system's default application (open on macOS, Invoke-Item on Windows, and xdg-open on desktop Linux). For .html, .htm, .xhtml, and .svg, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through wslpath -w and hands the resulting Windows/UNC path to Windows Invoke-Item, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as host.pickDirectory.

The command.* and skill.* domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by sessionId (a served session always has an Agent; command.* resumes cold sessions through the same path as session.*, while skill.list resolves the project root from the session header without touching the Agent registry). skill.list serves the composer's invocation path: it returns every user-invocable skill with its modelInvocable flag, so menus can mark user-only (disable-model-invocation) entries whose only entry point this is. skill.invoke is the user-explicit loading RPC: it enforces user-invocation policy at this boundary (skill-not-found / skill-not-invocable), renders the canonical <skill_content> body via the shared renderSkillContent, appends the optional trailing text, injects the whole as a user-role message carrying the skill-invocation source, and starts a turn through the same route-served refusal gate as session.prompt. command.execute runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle commandId when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged command/run/command/done lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so command.execute carries only caller/connection cancellation; that signal cancels the running handler. host/commands-changed is the catalog invalidation frame: clients refetch command.list instead of diffing.

The settings.*, credentials.*, and llm.* domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (ctx.llm.listConfigurableProviders()) plus a small explicit allowlist — the Web preference permission and the product-owned ui-onboarding; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers settings-not-exposed — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. settings.describe returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/base/user — a field's presence in user marks it user-overridden), the secrets slot list, the section's revision, and the boolean hasDocument capability flag. The browser receives no Host path: pathless settings.openDocument asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. settings.update/settings.replace write the user layer; settings.mutate applies path ops (set/unset) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry expectedRevision; a stale one answers settings-conflict with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into settings-rejected. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an update/mutate payload or credentials.set. credentials.describe returns value-free views (configured/source/writable), and credentials.set/credentials.unset map a shadowed-reference refusal onto credential-rejected. llm.providers merges the configurable-provider directory with live routes (dormant entries carry active: false; undeclared live routes append with no settings address) and llm.models is the session-independent catalog. llm.discoverModels interrogates a provider endpoint the page is still drafting: settingsNs selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later settings.mutate decides what a route serves — so its apiKey is the third payload on which a secret may ride, alongside settings.update/mutate and credentials.set. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which subscribeEnvelopes() observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into model-discovery-failed, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: host/settings-changed {ns} (settings/document-updated passthrough, so a raw change whose resolved value is unchanged still reaches clients), host/credentials-changed {ref} (reference names only, never values), and host/models-changed — fired by llm/adapters-updated and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a permission or ui-onboarding change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (settings.describe/openDocument/update/replace/mutate, credentials.describe/set/unset), to loopback same-origin requests — the host.pickDirectory privileged set. A composition without a settings or credential provider answers those domains with an actionable internal error naming the missing plugin.

Carrier layer (/client + root)

AbstractApiClient holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (subscribeEnvelopes) — while platform subclasses supply only the doFetch transport aspect. InProcessApiClient over toFetchHandler(api) is the isomorphic point: the full wire serialization/validation path with no network, used by dsh -p headless.

Model Experience

None, as the package defines the client↔host wire contract and carriers; nothing here reaches a model request.

KV Cache effect

None; this package neither assembles nor sends a provider request.

Known Limitations and Deferred Work

  • Pending-interaction state is host-side — the wire shape is POST /api/respond plus RpcReceipt; the table in src/api-proxy.ts handles questions only and has no approval entries.
  • Reserved seams stay out of RpcMethodMapprompt.mode: 'inject', task.list, and a describe hostInstanceId are documented reservations; model discovery uses llm.models. An unknown method fails loud at envelope parse rather than getting a not-implemented code.
  • No protocol version field — client and host ship together; host.describe gains a version negotiation field only when an independently released client exists.
  • Search failures include provider diagnostics — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
  • Linux native picker requires desktop tooling — under the native capability, host.pickDirectory reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the native backend README).
  • A cold session's updatedAt counts a mere pickup as a write (per-file backends only) — the attached projection excludes the session/end-seed boundary, because picking a session up is not activity, but a cold session's updatedAt is its log file's mtime and every durable write refreshes that, the boundary included. agentFor() resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where locate() resolves a per-session artifact, i.e. JSONL; SQLite returns undefined, so its cold sessions fall back to createdAt and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the last-activity-index Agent Note.