Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

The Client API carrier's `agentPresets` member was the one member of its class
without an `IApiClient[...]` annotation. Inferring it inlined `AgentPresetEntry`
into the emitted declaration by the specifier TS picks — the host `index.ts` —
dragging the whole gateway, and with it the host `Context` merges, into every
Client program importing the carrier. Annotated like its siblings.

`ApiRemoteAgentOptions.setup` now takes the inspected session rather than its
header alone: this layer resolves a resumed session's preset from the LOG,
because a session that switched while blank ran its turns under the newer
composition and the header is written once at creation.

Conflicts:
	apps/web/tests/snapshots/*/*.expected.md
	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
	packages/host/apiproxy/src/api-proxy.ts
	scripts/doc-budgets.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-08 15:00:31 +08:00
649 changed files with 21091 additions and 2838 deletions

View File

@@ -8,7 +8,7 @@ import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
@@ -29,7 +29,8 @@ import {
} from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
@@ -71,11 +72,27 @@ import type {
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import {
ApiRemoteSessionNotFound as SessionNotFound,
ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership,
apiRemoteSubagentOwnershipError,
createApiRemoteAgentResolver,
hasApiRemoteSubagentOwner,
inspectApiRemoteSession,
} from '@deepseek-ai/dsh-api-remotes'
import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/**
* The settings namespace carrying the user's default route. Named for the
* gateway rather than for the package, because this key is what a person reads
* and writes in `settings.yaml`; the row id in a composition happens to match
* but does not determine it.
*/
export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway')
/** Non-model settings namespaces intentionally served to the Web client. */
const WEB_SETTINGS_NAMESPACES = ['permission'] as const
@@ -369,8 +386,21 @@ function directoryError(error: unknown): RpcError {
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
export interface ApiProxyDefaults {
provider: string
model: string
/**
* The route a session starts from when its own log names none. Read on
* every access rather than captured, so a default saved during this process
* reaches the sessions that have not run a turn yet.
*/
defaultTarget: () => AgentLlmTarget
/**
* Record a selection as the new default. Either absent, or a closure that
* may itself decline — the gateway plugin always passes one, and it no-ops
* when the deployment mounts no settings provider or when the write races
* service teardown. A switch then stays process-local. A rejection is
* reported and swallowed: the switch already applies to its own session,
* and undoing it because storage failed would be the worse outcome.
*/
persistDefaultTarget?: (target: AgentLlmTarget) => Promise<void>
/** Default project directory for new sessions whose create request carries no cwd. */
cwd: string
/** Parent directory for name-created workspaces. */
@@ -691,19 +721,6 @@ async function catalogChild(
}
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
*/
class SessionNotFound extends Error {}
/** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */
class SubagentSessionOwnership extends Error {
constructor(readonly sessionId: SessionId) {
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
}
}
/**
* The requested preset differs from the one this session already runs.
*
@@ -794,11 +811,13 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
* @returns the ApiProxy implementation.
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
/** The seed route each create/resume declares; re-read so it never goes stale. */
const agentOptions = (): AgentOptions => {
const { provider, model } = defaults.defaultTarget()
return { provider, model }
}
type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
const targets = new WeakMap<Agent, WebLlmTargetRef>()
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
/**
* Serializes `agentPreset.select` per session. Two concurrent selects both
* pass the blank check, and the second `unmountPresetFor` then finds nothing
@@ -817,24 +836,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/**
* Install or return the session-local target that prompt assembly snapshots.
* Seed order: latest logged request/header, else the host default routing.
* There is no create-time per-session override tier on this wire — if one
* returns (a create-options contribution), it must fold in between the two.
*
* Precedence, resolved on EVERY read rather than seeded once: a selection
* made in this process, else the session's own latest logged request/header,
* else the live host default. Re-reading is what keeps the two tiers honest
* in both directions — a session that has run a turn derives its route from
* its log forever after, so changing the default never retargets it; and a
* session still blank (New Session reuses one rather than minting another)
* starts from a default saved after it was created. There is no create-time
* per-session override tier on this wire — if one returns (a create-options
* contribution), it must fold in between the selection and the log.
*/
function targetFor(agent: Agent): WebLlmTargetRef {
const installed = targets.get(agent)
if (installed !== undefined) return installed
const logged = agent.session.requestHeader()?.config
let picked: AgentLlmTarget | undefined
const target: WebLlmTargetRef = {
current: logged === undefined
? { provider: defaults.provider, model: defaults.model }
: {
get current(): AgentLlmTarget {
if (picked !== undefined) return picked
// Incrementally folded by the session, so a per-step read costs
// O(new events) rather than a rescan.
const logged = agent.session.requestHeader()?.config
if (logged === undefined) return defaults.defaultTarget()
return {
provider: logged.provider,
model: logged.model,
...logged.reasoningEffort === undefined
? {}
: { reasoningEffort: logged.reasoningEffort },
},
}
},
set current(next: AgentLlmTarget) {
picked = next
},
assembled: undefined,
}
installAgentLlmTarget(agent.ctx, target)
@@ -907,6 +941,29 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
const hasSubagentOwner = (
session: Pick<Session, 'header'>,
agent: Agent | undefined,
): boolean => hasApiRemoteSubagentOwner(ctx, session, agent)
const subagentOwnershipError = (sessionId: SessionId): RpcError =>
apiRemoteSubagentOwnershipError(sessionId)
const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> =>
inspectApiRemoteSession(ctx, sessionId)
// Cold resume composes the preset the session recorded, for the same reason
// `session.create` does: its history was produced under that composition.
// Every generic entry point — prompt, models, commands — arrives here, so
// leaving it out meant a session opened after a restart ran on host tools
// and the deployment persona. Resolved from the LOG, not the header: a
// session that switched while blank ran its turns under the newer
// composition, and the header is written once at creation. Reading the
// header here would silently undo the switch on the next restart and
// restore that history under the old tool set.
const agentFor = createApiRemoteAgentResolver(ctx, {
agentOptions,
setup: async ({ meta, events }) =>
(await composeAgent(resolveSessionPreset({ header: meta, events }))).setup,
})
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
const envelope = frame(payload)
@@ -1088,128 +1145,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
/**
* Generic Host interaction cannot claim a durably classified subagent
* (`origin: 'subagent'` in the header) or an Agent runtime-owned by its
* live parent.
*/
function hasSubagentOwner(
session: Pick<Session, 'header'>,
agent: Agent | undefined,
): boolean {
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}
/** Stable generic-Host error for an identity reserved to subagent routing. */
function subagentOwnershipError(sessionId: SessionId): RpcError {
return {
code: 'agent-busy',
message: `session "${sessionId}" is owned by subagent routing`,
details: { reason: 'use subagent delivery for this child session' },
}
}
/** Inspect one cold served session without repairing, resuming, or publishing it. */
async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
}
const meta = (await persistence.list()).find(m => m.id === sessionId)
if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
const inspected = await persistence.inspect(sessionId)
if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
return { meta: inspected.meta, events: [...inspected.events] }
}
/**
* Resolve one live registered identity through the subagent-ownership
* fence: subagent-owned agents answer `agent-busy`, plain agents pass.
* Fences the live agent's own session rather than trusting a
* "registered ⇒ attached-store" invariant — a registered subagent whose
* session is ever absent from the attached store must still not be handed
* out through generic Host routing. `undefined` means no live agent.
*/
function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined {
const live = ctx.agents.get(sessionId)
if (live === undefined) return undefined
if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) }
return { agent: live }
}
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
return { error: subagentOwnershipError(sessionId) }
}
let resume = resumes.get(sessionId)
if (resume === undefined) {
resume = (async () => {
try {
const inspected = await inspectServable(sessionId)
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
throw new SubagentSessionOwnership(sessionId)
}
const publishedSession = ctx.sessions.get(sessionId)
const publishedAgent = ctx.agents.get(sessionId)
if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
throw new SubagentSessionOwnership(sessionId)
}
// Cold resume composes the preset the session recorded, for the
// same reason `session.create` does: its history was produced under
// that composition. Every generic entry point — prompt, models,
// commands — arrives here, so leaving it out meant a session opened
// after a restart ran on host tools and the deployment persona.
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
// Resolved from the LOG, not the header: a session that switched
// while blank ran its turns under the newer composition, and the
// header is written once at creation. Reading the header here
// would silently undo the switch on the next restart and restore
// that history under the old tool set.
setup: (await composeAgent(
resolveSessionPreset({ header: inspected.meta, events: inspected.events }),
)).setup,
})
return handle.agent
} finally {
resumes.delete(sessionId)
}
})()
resumes.set(sessionId, resume)
}
try {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
}
if (error instanceof SubagentSessionOwnership) {
return { error: subagentOwnershipError(error.sessionId) }
}
// A concurrent publish can win the identity between the pre-resume
// re-check and `ctx.agents.resume` publication; the ID-collision
// rejection falls through here. Mirror ensureSession's `.catch` in
// full: classify a subagent-owned winner into the stable ownership
// error, and hand a clean plain-agent winner straight back.
const fenced = fencedLiveAgent(sessionId)
if (fenced !== undefined) return fenced
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
return { error: subagentOwnershipError(sessionId) }
}
// The internal details slot is contractually {}; the reason rides the message.
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
}
}
type SessionReadState = {
id: SessionId
header: SessionHeader
@@ -1305,7 +1240,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// longer make.
return (await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
agentOptions: agentOptions(),
setup: (await composeAgent(storedPreset)).setup,
})).agent
}
@@ -1318,7 +1253,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const composition = await composeAgent(presetId)
return (await ctx.agents.create({
sessionId,
agentOptions,
agentOptions: agentOptions(),
meta: {
cwd,
...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
@@ -1481,6 +1416,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
/**
* Whether an adapter currently serves this route, and therefore whether a
* session pointed at it can start a turn. Catalog membership cannot answer
* it: an adapter may serve a model its own catalog stopped advertising, so
* a route missing from the groups is not the same as one nothing serves.
* A composition with no llm registry at all cannot judge and says yes —
* the dispatch it would have refused fails on its own terms.
*/
function routeServed(provider: string): boolean {
const llm = ctx.get('llm')
return llm === undefined || llm.listProviders().some(entry => entry.id === provider)
}
/** Missing-service report shared by the settings domain (skills-domain stance). */
function settingsAbsent(): RpcError {
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
@@ -1869,7 +1817,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if ('error' in found) return err(request, found.error)
const current = targetFor(found.agent).current
const { groups, failures } = await buildModelCatalog(ctx)
return ok(request, { current: { ...current }, groups, failures })
const routable = routeServed(current.provider)
return ok(request, { current: { ...current }, routable, groups, failures })
},
async selectModel(request) {
@@ -1892,6 +1841,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
: { reasoningEffort: resolved.reasoningEffort },
}
targetFor(found.agent).current = selected
// A switch is also how this deployment's default is chosen: the next
// session created without one of its own starts here. Sessions that
// have already logged a route are unaffected — they derive from
// their own log (see targetFor).
try {
await defaults.persistDefaultTarget?.(selected)
} catch (error: unknown) {
ctx.logger.warn(
`api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
)
}
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
return err(request, {
@@ -2003,7 +1963,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
? {}
: { agentPreset: forkComposition.agentPreset },
},
agentOptions,
agentOptions: agentOptions(),
setup: forkComposition.setup,
})
} catch (error: unknown) {
@@ -2035,6 +1995,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
// A route no adapter serves cannot start a turn, and letting it try
// spends the whole pre-step path to fail inside the adapter with a
// message about registration. Refusing here names the model the
// session is pointed at while the draft is still in the composer.
// This is the enforcement boundary: a client that disables its input
// is an affordance, and this method stays callable regardless.
const target = targetFor(agent).current
if (!routeServed(target.provider)) {
return err(request, {
code: 'model-unavailable',
message: `no adapter serves provider "${target.provider}"; select a model for this session`,
details: { provider: target.provider, model: target.model },
})
}
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
@@ -2388,13 +2362,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
host: {
describe(request) {
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
const route = defaults.defaultTarget()
return Promise.resolve(ok(request, {
version: '0.0.1',
// Same source as session.create's fallback: the UI's default project
// must match where an unspecified-cwd session actually lands.
cwd: defaults.cwd,
provider: defaults.provider,
model: defaults.model,
// Read live for the same reason: this is what the NEXT session will
// start from, so a saved default has to be what it reports.
provider: route.provider,
model: route.model,
attachedSessions: ctx.agents.list().length,
}))
},
@@ -2801,15 +2778,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const active = new Set(registered.map(provider => provider.id))
const directory = ctx.llm.listConfigurableProviders()
const declared = new Set(directory.map(entry => entry.provider))
const views = directory.map(entry => ({
const views: ConfigurableProviderView[] = directory.map(entry => ({
provider: entry.provider,
displayName: entry.displayName,
settingsNs: entry.settingsNs,
settingsPath: [...entry.settingsPath],
active: active.has(entry.provider),
...entry.declared === undefined ? {} : { declared: entry.declared },
}))
// Routes registered without a directory declaration still appear —
// they exist and serve models — just with no settings address.
// they exist and serve models — just with no settings address. No
// adapter claimed them, so nothing can say whether they are shipped.
for (const provider of registered) {
if (declared.has(provider.id)) continue
views.push({
@@ -3001,8 +2980,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'host/settings-changed', ns: name }))
// A provider's own settings carry its model catalog and endpoint,
// so a change there invalidates the model list even when the route
// set is untouched — `llm/adapters-updated` alone misses it.
if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' }))
// set is untouched — `llm/adapters-updated` alone misses it. The
// gateway's own section is the other such source: it names the
// route every session with no logged one resolves to, so an
// externally edited default (another tab, a hand-edited
// settings.yaml) has to reach an open selector too.
if (modelProviderNamespaces().has(name) || name === String(API_GATEWAY_SETTINGS_NAMESPACE)) {
queue.push(frame({ type: 'host/models-changed' }))
}
}),
ctx.on('credentials/updated', (ref) => {
queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))